@plinth-music/cli 0.2.0 → 0.3.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/cli.js +1214 -336
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -4102,11 +4102,11 @@ var require_excerpt = __commonJS((exports, module) => {
4102
4102
  if (typeof opts.excerpt === "function") {
4103
4103
  return opts.excerpt(file2, opts);
4104
4104
  }
4105
- const sep2 = file2.data.excerpt_separator || opts.excerpt_separator;
4106
- if (sep2 == null && (opts.excerpt === false || opts.excerpt == null)) {
4105
+ const sep3 = file2.data.excerpt_separator || opts.excerpt_separator;
4106
+ if (sep3 == null && (opts.excerpt === false || opts.excerpt == null)) {
4107
4107
  return file2;
4108
4108
  }
4109
- const delimiter = typeof opts.excerpt === "string" ? opts.excerpt : sep2 || opts.delimiters[0];
4109
+ const delimiter = typeof opts.excerpt === "string" ? opts.excerpt : sep3 || opts.delimiters[0];
4110
4110
  const idx = file2.content.indexOf(delimiter);
4111
4111
  if (idx !== -1) {
4112
4112
  file2.excerpt = file2.content.slice(0, idx);
@@ -5570,15 +5570,15 @@ class AsyncTaskManager {
5570
5570
  this.#browserFrame = browserFrame;
5571
5571
  }
5572
5572
  waitUntilComplete() {
5573
- return new Promise((resolve, reject) => {
5574
- this.waitUntilCompleteResolvers.push({ resolve, reject });
5573
+ return new Promise((resolve2, reject) => {
5574
+ this.waitUntilCompleteResolvers.push({ resolve: resolve2, reject });
5575
5575
  this.resolveWhenComplete();
5576
5576
  });
5577
5577
  }
5578
5578
  abort() {
5579
5579
  if (this.destroyed) {
5580
- return new Promise((resolve, reject) => {
5581
- this.waitUntilCompleteResolvers.push({ resolve, reject });
5580
+ return new Promise((resolve2, reject) => {
5581
+ this.waitUntilCompleteResolvers.push({ resolve: resolve2, reject });
5582
5582
  this.resolveWhenComplete();
5583
5583
  });
5584
5584
  }
@@ -5586,8 +5586,8 @@ class AsyncTaskManager {
5586
5586
  }
5587
5587
  destroy() {
5588
5588
  if (this.destroyed) {
5589
- return new Promise((resolve, reject) => {
5590
- this.waitUntilCompleteResolvers.push({ resolve, reject });
5589
+ return new Promise((resolve2, reject) => {
5590
+ this.waitUntilCompleteResolvers.push({ resolve: resolve2, reject });
5591
5591
  this.resolveWhenComplete();
5592
5592
  });
5593
5593
  }
@@ -5773,8 +5773,8 @@ The following traces were recorded:
5773
5773
  for (const key of Object.keys(runningTasks)) {
5774
5774
  runningTasks[key](destroy2);
5775
5775
  }
5776
- return new Promise((resolve, reject) => {
5777
- this.waitUntilCompleteResolvers.push({ resolve, reject });
5776
+ return new Promise((resolve2, reject) => {
5777
+ this.waitUntilCompleteResolvers.push({ resolve: resolve2, reject });
5778
5778
  this.resolveWhenComplete();
5779
5779
  });
5780
5780
  }
@@ -16194,12 +16194,12 @@ var init_CustomElementRegistry = __esm(() => {
16194
16194
  if (this.get(name2)) {
16195
16195
  return Promise.resolve();
16196
16196
  }
16197
- return new Promise((resolve) => {
16197
+ return new Promise((resolve2) => {
16198
16198
  const callbacks2 = this[callbacks].get(name2);
16199
16199
  if (callbacks2) {
16200
- callbacks2.push(resolve);
16200
+ callbacks2.push(resolve2);
16201
16201
  } else {
16202
- this[callbacks].set(name2, [resolve]);
16202
+ this[callbacks].set(name2, [resolve2]);
16203
16203
  }
16204
16204
  });
16205
16205
  }
@@ -18788,7 +18788,7 @@ class Fetch {
18788
18788
  return true;
18789
18789
  }
18790
18790
  sendRequest() {
18791
- return new Promise((resolve, reject) => {
18791
+ return new Promise((resolve2, reject) => {
18792
18792
  const taskID = this.#browserFrame[asyncTaskManager].startTask(() => this.onAsyncTaskManagerAbort());
18793
18793
  if (this.resolve) {
18794
18794
  throw new this.#window.Error("Fetch already sent.");
@@ -18812,7 +18812,7 @@ class Fetch {
18812
18812
  if (returnResponse instanceof Response && !returnResponse.ok) {
18813
18813
  this.#browserFrame.page.console.error(`${this.request.method} ${this.request.url} ${returnResponse.status} (${returnResponse.statusText})`);
18814
18814
  }
18815
- resolve(returnResponse);
18815
+ resolve2(returnResponse);
18816
18816
  };
18817
18817
  this.reject = (error53) => {
18818
18818
  this.#browserFrame[asyncTaskManager].endTask(taskID);
@@ -27205,9 +27205,9 @@ class BrowserFrameFactory {
27205
27205
  return Promise.resolve();
27206
27206
  }
27207
27207
  frame.closed = true;
27208
- return new Promise((resolve, reject) => {
27208
+ return new Promise((resolve2, reject) => {
27209
27209
  if (!frame.window) {
27210
- resolve();
27210
+ resolve2();
27211
27211
  return;
27212
27212
  }
27213
27213
  if (frame.parentFrame) {
@@ -27227,7 +27227,7 @@ class BrowserFrameFactory {
27227
27227
  if (frame[listeners]) {
27228
27228
  frame[listeners].navigation = [];
27229
27229
  }
27230
- resolve();
27230
+ resolve2();
27231
27231
  }).catch((error53) => reject(error53));
27232
27232
  if (frame.window) {
27233
27233
  frame.window[destroy]();
@@ -27245,7 +27245,7 @@ class BrowserFrameFactory {
27245
27245
  if (frame[listeners]) {
27246
27246
  frame[listeners].navigation = [];
27247
27247
  }
27248
- resolve();
27248
+ resolve2();
27249
27249
  }).catch((error53) => reject(error53));
27250
27250
  if (frame.window) {
27251
27251
  frame.window[destroy]();
@@ -27351,15 +27351,15 @@ class BrowserFrameNavigator {
27351
27351
  const asyncTaskManager3 = frame[asyncTaskManager];
27352
27352
  const taskID2 = readyStateManager3.startTask();
27353
27353
  const code = targetURL.href.replace("javascript:", "");
27354
- await new Promise((resolve) => {
27354
+ await new Promise((resolve2) => {
27355
27355
  frame.window.requestAnimationFrame(() => {
27356
27356
  const immediate = setImmediate(() => {
27357
27357
  asyncTaskManager3.endTask(taskID3);
27358
- resolve(null);
27358
+ resolve2(null);
27359
27359
  });
27360
27360
  const taskID3 = asyncTaskManager3.startTask(() => () => {
27361
27361
  clearImmediate(immediate);
27362
- resolve(null);
27362
+ resolve2(null);
27363
27363
  });
27364
27364
  frame.window[evaluateScript](code, { filename: frame.url });
27365
27365
  });
@@ -27430,7 +27430,7 @@ class BrowserFrameNavigator {
27430
27430
  if (frame.page.context.browser.settings.navigation.beforeContentCallback) {
27431
27431
  frame.page.context.browser.settings.navigation.beforeContentCallback(frame.window);
27432
27432
  }
27433
- await new Promise((resolve) => frame.page.mainFrame.window.requestAnimationFrame(resolve));
27433
+ await new Promise((resolve2) => frame.page.mainFrame.window.requestAnimationFrame(resolve2));
27434
27434
  resolveNavigationListeners();
27435
27435
  return null;
27436
27436
  }
@@ -27499,16 +27499,16 @@ class BrowserFrameNavigator {
27499
27499
  if (frame.page.context.browser.settings.navigation.beforeContentCallback) {
27500
27500
  frame.page.context.browser.settings.navigation.beforeContentCallback(frame.window);
27501
27501
  }
27502
- await new Promise((resolve) => {
27502
+ await new Promise((resolve2) => {
27503
27503
  frame.window.requestAnimationFrame(() => {
27504
27504
  let immediate;
27505
27505
  const taskID2 = asyncTaskManager2.startTask(() => () => {
27506
27506
  clearImmediate(immediate);
27507
- resolve(null);
27507
+ resolve2(null);
27508
27508
  });
27509
27509
  immediate = setImmediate(() => {
27510
27510
  asyncTaskManager2.endTask(taskID2);
27511
- resolve(null);
27511
+ resolve2(null);
27512
27512
  });
27513
27513
  frame.content = responseText;
27514
27514
  });
@@ -27521,14 +27521,14 @@ class BrowserFrameNavigator {
27521
27521
  const history2 = frame[history];
27522
27522
  const historyItem = history2.items[history2.items.indexOf(history2.currentItem) - 1];
27523
27523
  if (!historyItem) {
27524
- return new Promise((resolve) => {
27524
+ return new Promise((resolve2) => {
27525
27525
  frame.window.requestAnimationFrame(() => {
27526
27526
  const listeners2 = frame[listeners].navigation;
27527
27527
  frame[listeners].navigation = [];
27528
27528
  for (const listener of listeners2) {
27529
27529
  listener();
27530
27530
  }
27531
- resolve(null);
27531
+ resolve2(null);
27532
27532
  });
27533
27533
  });
27534
27534
  }
@@ -27561,14 +27561,14 @@ class BrowserFrameNavigator {
27561
27561
  const history2 = frame[history];
27562
27562
  const historyItem = history2.items[history2.items.indexOf(history2.currentItem) + 1];
27563
27563
  if (!historyItem) {
27564
- return new Promise((resolve) => {
27564
+ return new Promise((resolve2) => {
27565
27565
  frame.window.requestAnimationFrame(() => {
27566
27566
  const listeners2 = frame[listeners].navigation;
27567
27567
  frame[listeners].navigation = [];
27568
27568
  for (const listener of listeners2) {
27569
27569
  listener();
27570
27570
  }
27571
- resolve(null);
27571
+ resolve2(null);
27572
27572
  });
27573
27573
  });
27574
27574
  }
@@ -27606,14 +27606,14 @@ class BrowserFrameNavigator {
27606
27606
  const toIndex = fromIndex + steps;
27607
27607
  const historyItem = history2.items[toIndex];
27608
27608
  if (!historyItem) {
27609
- return new Promise((resolve) => {
27609
+ return new Promise((resolve2) => {
27610
27610
  frame.window.requestAnimationFrame(() => {
27611
27611
  const listeners2 = frame[listeners].navigation;
27612
27612
  frame[listeners].navigation = [];
27613
27613
  for (const listener of listeners2) {
27614
27614
  listener();
27615
27615
  }
27616
- resolve(null);
27616
+ resolve2(null);
27617
27617
  });
27618
27618
  });
27619
27619
  }
@@ -30379,12 +30379,12 @@ class UnresolvedModule {
30379
30379
  async preload() {
30380
30380
  throw new this.#window.TypeError("Unresolved module. We should never end up here.");
30381
30381
  }
30382
- addResolveListener(resolve, reject) {
30382
+ addResolveListener(resolve2, reject) {
30383
30383
  if (this.#error) {
30384
30384
  reject(this.#error);
30385
30385
  return;
30386
30386
  }
30387
- this.#hooks.push({ resolve, reject });
30387
+ this.#hooks.push({ resolve: resolve2, reject });
30388
30388
  }
30389
30389
  resolve(error53) {
30390
30390
  if (error53) {
@@ -31150,8 +31150,8 @@ var init_ECMAScriptModule = __esm(() => {
31150
31150
  }
31151
31151
  return this.#exports;
31152
31152
  }
31153
- return new Promise((resolve) => {
31154
- this.#evaluateQueue.push(resolve);
31153
+ return new Promise((resolve2) => {
31154
+ this.#evaluateQueue.push(resolve2);
31155
31155
  });
31156
31156
  }
31157
31157
  if (this.#exports) {
@@ -31206,8 +31206,8 @@ var init_ECMAScriptModule = __esm(() => {
31206
31206
  });
31207
31207
  const evaluateQueue = this.#evaluateQueue;
31208
31208
  this.#evaluateQueue = null;
31209
- for (const resolve of evaluateQueue) {
31210
- resolve(exports);
31209
+ for (const resolve2 of evaluateQueue) {
31210
+ resolve2(exports);
31211
31211
  }
31212
31212
  return exports;
31213
31213
  }
@@ -31267,8 +31267,8 @@ class ModuleFactory {
31267
31267
  const cached2 = window3[modules][type2].get(absoluteURLString);
31268
31268
  if (cached2) {
31269
31269
  if (cached2 instanceof UnresolvedModule) {
31270
- await new Promise((resolve, reject) => {
31271
- cached2.addResolveListener(resolve, reject);
31270
+ await new Promise((resolve2, reject) => {
31271
+ cached2.addResolveListener(resolve2, reject);
31272
31272
  });
31273
31273
  return window3[modules][type2].get(absoluteURLString);
31274
31274
  }
@@ -31342,8 +31342,8 @@ class PreloadEntry {
31342
31342
  error = null;
31343
31343
  #callback = null;
31344
31344
  onResponseAvailable() {
31345
- return new Promise((resolve, reject) => {
31346
- this.#callback = { resolve, reject };
31345
+ return new Promise((resolve2, reject) => {
31346
+ this.#callback = { resolve: resolve2, reject };
31347
31347
  });
31348
31348
  }
31349
31349
  responseAvailable(error53, response) {
@@ -54743,14 +54743,14 @@ var init_BrowserFrame = __esm(() => {
54743
54743
  ]);
54744
54744
  }
54745
54745
  waitForNavigation() {
54746
- return new Promise((resolve) => this[listeners].navigation.push(resolve));
54746
+ return new Promise((resolve2) => this[listeners].navigation.push(resolve2));
54747
54747
  }
54748
54748
  abort() {
54749
54749
  if (!this.childFrames.length) {
54750
54750
  return this[asyncTaskManager].abort();
54751
54751
  }
54752
- return new Promise((resolve, reject) => {
54753
- Promise.all(this.childFrames.map((frame) => frame.abort()).concat([this[asyncTaskManager].abort()])).then(() => resolve()).catch(reject);
54752
+ return new Promise((resolve2, reject) => {
54753
+ Promise.all(this.childFrames.map((frame) => frame.abort()).concat([this[asyncTaskManager].abort()])).then(() => resolve2()).catch(reject);
54754
54754
  });
54755
54755
  }
54756
54756
  evaluate(script) {
@@ -55205,12 +55205,12 @@ var init_BrowserContext = __esm(() => {
55205
55205
  await Promise.all(this.pages.map((page) => page.waitUntilComplete()));
55206
55206
  }
55207
55207
  abort() {
55208
- return new Promise((resolve, reject) => {
55208
+ return new Promise((resolve2, reject) => {
55209
55209
  if (!this.pages.length) {
55210
- resolve();
55210
+ resolve2();
55211
55211
  return;
55212
55212
  }
55213
- Promise.all(this.pages.slice().map((page) => page.abort())).then(() => resolve()).catch((error53) => reject(error53));
55213
+ Promise.all(this.pages.slice().map((page) => page.abort())).then(() => resolve2()).catch((error53) => reject(error53));
55214
55214
  });
55215
55215
  }
55216
55216
  newPage() {
@@ -55515,14 +55515,14 @@ var init_DetachedBrowserFrame = __esm(() => {
55515
55515
  ]);
55516
55516
  }
55517
55517
  waitForNavigation() {
55518
- return new Promise((resolve) => this[listeners].navigation.push(resolve));
55518
+ return new Promise((resolve2) => this[listeners].navigation.push(resolve2));
55519
55519
  }
55520
55520
  abort() {
55521
55521
  if (!this.childFrames.length) {
55522
55522
  return this[asyncTaskManager].abort();
55523
55523
  }
55524
- return new Promise((resolve, reject) => {
55525
- Promise.all(this.childFrames.map((frame) => frame.abort()).concat([this[asyncTaskManager].abort()])).then(() => resolve()).catch(reject);
55524
+ return new Promise((resolve2, reject) => {
55525
+ Promise.all(this.childFrames.map((frame) => frame.abort()).concat([this[asyncTaskManager].abort()])).then(() => resolve2()).catch(reject);
55526
55526
  });
55527
55527
  }
55528
55528
  evaluate(script) {
@@ -55605,13 +55605,13 @@ class DetachedBrowserPage {
55605
55605
  this.mainFrame.url = url3;
55606
55606
  }
55607
55607
  close() {
55608
- return new Promise((resolve, reject) => {
55608
+ return new Promise((resolve2, reject) => {
55609
55609
  const context = this.context;
55610
55610
  BrowserPageUtility.closePage(this).then(() => {
55611
55611
  if (context.pages[0] === this) {
55612
- context.browser.close().then(resolve).catch(reject);
55612
+ context.browser.close().then(resolve2).catch(reject);
55613
55613
  } else {
55614
- resolve();
55614
+ resolve2();
55615
55615
  }
55616
55616
  }).catch(reject);
55617
55617
  });
@@ -55705,12 +55705,12 @@ var init_DetachedBrowserContext = __esm(() => {
55705
55705
  await Promise.all(this.pages.map((page) => page.waitUntilComplete()));
55706
55706
  }
55707
55707
  abort() {
55708
- return new Promise((resolve, reject) => {
55708
+ return new Promise((resolve2, reject) => {
55709
55709
  if (!this.pages.length) {
55710
- resolve();
55710
+ resolve2();
55711
55711
  return;
55712
55712
  }
55713
- Promise.all(this.pages.slice().map((page) => page.abort())).then(() => resolve()).catch((error53) => reject(error53));
55713
+ Promise.all(this.pages.slice().map((page) => page.abort())).then(() => resolve2()).catch((error53) => reject(error53));
55714
55714
  });
55715
55715
  }
55716
55716
  newPage() {
@@ -55765,12 +55765,12 @@ var init_DetachedBrowser = __esm(() => {
55765
55765
  await Promise.all(this.contexts.map((page) => page.waitUntilComplete()));
55766
55766
  }
55767
55767
  abort() {
55768
- return new Promise((resolve, reject) => {
55768
+ return new Promise((resolve2, reject) => {
55769
55769
  if (!this.contexts.length) {
55770
- resolve();
55770
+ resolve2();
55771
55771
  return;
55772
55772
  }
55773
- Promise.all(this.contexts.slice().map((context) => context.abort())).then(() => resolve()).catch((error53) => reject(error53));
55773
+ Promise.all(this.contexts.slice().map((context) => context.abort())).then(() => resolve2()).catch((error53) => reject(error53));
55774
55774
  });
55775
55775
  }
55776
55776
  newIncognitoContext() {
@@ -73049,7 +73049,7 @@ var require_DOMImplementation = __commonJS((exports, module) => {
73049
73049
  utils.InvalidCharacterError();
73050
73050
  return new DocumentType2(this.contextObject, qualifiedName, publicId2, systemId2);
73051
73051
  },
73052
- createDocument: function createDocument(namespace, qualifiedName, doctype) {
73052
+ createDocument: function createDocument2(namespace, qualifiedName, doctype) {
73053
73053
  var d3 = new Document2(false, null);
73054
73054
  var e2;
73055
73055
  if (qualifiedName)
@@ -73931,14 +73931,14 @@ var require_turndown_cjs = __commonJS((exports, module) => {
73931
73931
  } else if (node.nodeType === 1) {
73932
73932
  replacement = replacementForNode.call(self2, node);
73933
73933
  }
73934
- return join3(output, replacement);
73934
+ return join4(output, replacement);
73935
73935
  }, "");
73936
73936
  }
73937
73937
  function postProcess(output) {
73938
73938
  var self2 = this;
73939
73939
  this.rules.forEach(function(rule) {
73940
73940
  if (typeof rule.append === "function") {
73941
- output = join3(output, rule.append(self2.options));
73941
+ output = join4(output, rule.append(self2.options));
73942
73942
  }
73943
73943
  });
73944
73944
  return output.replace(/^[\t\r\n]+/, "").replace(/[\t\r\n\s]+$/, "");
@@ -73951,7 +73951,7 @@ var require_turndown_cjs = __commonJS((exports, module) => {
73951
73951
  content2 = content2.trim();
73952
73952
  return whitespace.leading + rule.replacement(content2, node, this.options) + whitespace.trailing;
73953
73953
  }
73954
- function join3(output, replacement) {
73954
+ function join4(output, replacement) {
73955
73955
  var s1 = trimTrailingNewlines(output);
73956
73956
  var s2 = trimLeadingNewlines(replacement);
73957
73957
  var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
@@ -75578,7 +75578,7 @@ async function runMain(cmd, opts = {}) {
75578
75578
  // package.json
75579
75579
  var package_default = {
75580
75580
  name: "@plinth-music/cli",
75581
- version: "0.2.0",
75581
+ version: "0.3.0",
75582
75582
  description: "Plinth capstone sync daemon and CLI",
75583
75583
  type: "module",
75584
75584
  private: false,
@@ -89969,6 +89969,12 @@ var PatchConflictResponseSchema = exports_external.object({
89969
89969
  code: exports_external.literal("precondition_failed"),
89970
89970
  current_updated_at: exports_external.string().datetime({ offset: true })
89971
89971
  });
89972
+ var SlugConflictResponseSchema = exports_external.object({
89973
+ error: exports_external.string(),
89974
+ code: exports_external.literal("slug_conflict"),
89975
+ existing: SyncDocumentLiveSchema
89976
+ });
89977
+ var DeleteDocumentResponseSchema = SyncDocumentTombstoneSchema;
89972
89978
 
89973
89979
  // src/lib/api.ts
89974
89980
  class ApiError extends Error {
@@ -90043,9 +90049,18 @@ async function getDocument(args) {
90043
90049
  return parsed.data;
90044
90050
  }
90045
90051
  async function patchDocument(args) {
90046
- const { baseUrl, pat, id, expectedUpdatedAt, body, frontmatter, fetchImpl = fetch } = args;
90047
- if (body === undefined && frontmatter === undefined) {
90048
- throw new Error("patchDocument requires at least one of body or frontmatter");
90052
+ const {
90053
+ baseUrl,
90054
+ pat,
90055
+ id,
90056
+ expectedUpdatedAt,
90057
+ body,
90058
+ frontmatter,
90059
+ slug,
90060
+ fetchImpl = fetch
90061
+ } = args;
90062
+ if (body === undefined && frontmatter === undefined && slug === undefined) {
90063
+ throw new Error("patchDocument requires at least one of body, frontmatter, or slug");
90049
90064
  }
90050
90065
  const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/documents/${id}`;
90051
90066
  const payload = {};
@@ -90053,6 +90068,8 @@ async function patchDocument(args) {
90053
90068
  payload.body = body;
90054
90069
  if (frontmatter !== undefined)
90055
90070
  payload.frontmatter = frontmatter;
90071
+ if (slug !== undefined)
90072
+ payload.slug = slug;
90056
90073
  let res;
90057
90074
  try {
90058
90075
  res = await fetchImpl(url2, {
@@ -90082,11 +90099,25 @@ async function patchDocument(args) {
90082
90099
  }
90083
90100
  if (res.status === 409) {
90084
90101
  const json2 = await res.json();
90085
- const parsed = PatchConflictResponseSchema.safeParse(json2);
90086
- if (!parsed.success) {
90087
- throw new ApiError(409, "patch document 409 body did not match the expected shape");
90102
+ const slugParse = SlugConflictResponseSchema.safeParse(json2);
90103
+ if (slugParse.success) {
90104
+ return {
90105
+ ok: false,
90106
+ code: 409,
90107
+ reason: "slug_conflict",
90108
+ existing: slugParse.data.existing
90109
+ };
90088
90110
  }
90089
- return { ok: false, code: 409, currentUpdatedAt: parsed.data.current_updated_at };
90111
+ const preconditionParse = PatchConflictResponseSchema.safeParse(json2);
90112
+ if (preconditionParse.success) {
90113
+ return {
90114
+ ok: false,
90115
+ code: 409,
90116
+ reason: "precondition_failed",
90117
+ currentUpdatedAt: preconditionParse.data.current_updated_at
90118
+ };
90119
+ }
90120
+ throw new ApiError(409, "patch document 409 body did not match either precondition_failed or slug_conflict shape");
90090
90121
  }
90091
90122
  if (res.status === 401) {
90092
90123
  throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
@@ -90143,11 +90174,135 @@ async function syncDocuments(args) {
90143
90174
  }
90144
90175
  return parsed.data;
90145
90176
  }
90177
+ async function createDocument(args) {
90178
+ const {
90179
+ baseUrl,
90180
+ pat,
90181
+ slug,
90182
+ title,
90183
+ body,
90184
+ frontmatter,
90185
+ artist_id,
90186
+ project_id,
90187
+ fetchImpl = fetch
90188
+ } = args;
90189
+ const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/documents`;
90190
+ const payload = { slug, title, body, frontmatter };
90191
+ if (artist_id !== undefined)
90192
+ payload.artist_id = artist_id;
90193
+ if (project_id !== undefined)
90194
+ payload.project_id = project_id;
90195
+ let res;
90196
+ try {
90197
+ res = await fetchImpl(url2, {
90198
+ method: "POST",
90199
+ headers: {
90200
+ Authorization: `Bearer ${pat}`,
90201
+ Accept: "application/json",
90202
+ "Content-Type": "application/json"
90203
+ },
90204
+ body: JSON.stringify(payload)
90205
+ });
90206
+ } catch (err) {
90207
+ const msg = err instanceof Error ? err.message : "network error";
90208
+ throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
90209
+ }
90210
+ if (res.status === 200 || res.status === 201) {
90211
+ const json2 = await res.json();
90212
+ const parsed = SyncDocumentResponseSchema.safeParse(json2);
90213
+ if (!parsed.success) {
90214
+ throw new ApiError(res.status, "create document response did not match the expected shape");
90215
+ }
90216
+ return { ok: true, doc: parsed.data };
90217
+ }
90218
+ if (res.status === 409) {
90219
+ const json2 = await res.json();
90220
+ const parsed = SlugConflictResponseSchema.safeParse(json2);
90221
+ if (!parsed.success) {
90222
+ throw new ApiError(409, "create document 409 body did not match slug_conflict shape");
90223
+ }
90224
+ return { ok: false, code: 409, existing: parsed.data.existing };
90225
+ }
90226
+ if (res.status === 401) {
90227
+ throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
90228
+ }
90229
+ if (res.status === 429) {
90230
+ throw new ApiError(429, "Rate limited by the server. Wait a few seconds and retry.");
90231
+ }
90232
+ if (res.status === 400) {
90233
+ let serverMsg = "bad request";
90234
+ try {
90235
+ const json2 = await res.json();
90236
+ if (typeof json2.error === "string")
90237
+ serverMsg = json2.error;
90238
+ } catch {}
90239
+ throw new ApiError(400, serverMsg);
90240
+ }
90241
+ throw new ApiError(res.status, `create document failed with HTTP ${res.status}`);
90242
+ }
90243
+ async function deleteDocument(args) {
90244
+ const { baseUrl, pat, id, expectedUpdatedAt, fetchImpl = fetch } = args;
90245
+ const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/documents/${id}`;
90246
+ let res;
90247
+ try {
90248
+ res = await fetchImpl(url2, {
90249
+ method: "DELETE",
90250
+ headers: {
90251
+ Authorization: `Bearer ${pat}`,
90252
+ Accept: "application/json",
90253
+ "X-Plinth-Expected-Updated-At": expectedUpdatedAt
90254
+ }
90255
+ });
90256
+ } catch (err) {
90257
+ const msg = err instanceof Error ? err.message : "network error";
90258
+ throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
90259
+ }
90260
+ if (res.status === 200) {
90261
+ const json2 = await res.json();
90262
+ const parsed = DeleteDocumentResponseSchema.safeParse(json2);
90263
+ if (!parsed.success) {
90264
+ throw new ApiError(200, "delete document response did not match the expected tombstone shape");
90265
+ }
90266
+ return { ok: true, tombstone: parsed.data };
90267
+ }
90268
+ if (res.status === 404) {
90269
+ return { ok: false, code: 404 };
90270
+ }
90271
+ if (res.status === 409) {
90272
+ const json2 = await res.json();
90273
+ const parsed = PatchConflictResponseSchema.safeParse(json2);
90274
+ if (!parsed.success) {
90275
+ throw new ApiError(409, "delete document 409 body did not match precondition_failed shape");
90276
+ }
90277
+ return {
90278
+ ok: false,
90279
+ code: 409,
90280
+ currentUpdatedAt: parsed.data.current_updated_at
90281
+ };
90282
+ }
90283
+ if (res.status === 401) {
90284
+ throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
90285
+ }
90286
+ if (res.status === 429) {
90287
+ throw new ApiError(429, "Rate limited by the server. Wait a few seconds and retry.");
90288
+ }
90289
+ if (res.status === 400) {
90290
+ let serverMsg = "bad request";
90291
+ try {
90292
+ const json2 = await res.json();
90293
+ if (typeof json2.error === "string")
90294
+ serverMsg = json2.error;
90295
+ } catch {}
90296
+ throw new ApiError(400, serverMsg);
90297
+ }
90298
+ throw new ApiError(res.status, `delete document failed with HTTP ${res.status}`);
90299
+ }
90146
90300
 
90147
90301
  // src/lib/pat.ts
90148
- var PAT_REGEX = /^plnth_pat_[a-z2-7]{52}$/;
90149
- function isPlausiblePat(raw) {
90150
- return PAT_REGEX.test(raw);
90302
+ var PAT_SEARCH_REGEX = /plnth_pat_[a-z2-7]{52}/;
90303
+ function extractPat(raw) {
90304
+ const match = raw.match(PAT_SEARCH_REGEX);
90305
+ return match ? match[0] : null;
90151
90306
  }
90152
90307
  function patLastFour(raw) {
90153
90308
  return raw.slice(-4);
@@ -90158,9 +90313,89 @@ var ETX = "\x03";
90158
90313
  var BACKSPACE = "\b";
90159
90314
  var DEL = "";
90160
90315
  var CSI_RE = /\x1b\[[\d;]*[A-Za-z~]/g;
90316
+ var PASTE_START = "\x1B[200~";
90317
+ var PASTE_END = "\x1B[201~";
90318
+ var ENTER_CSI = /^\x1b\[13(?:;\d+)?u$/;
90161
90319
  function sanitizeInput(input) {
90162
90320
  return input.replace(CSI_RE, "");
90163
90321
  }
90322
+ var INITIAL_READER_STATE = {
90323
+ buf: "",
90324
+ pending: "",
90325
+ inPaste: false
90326
+ };
90327
+ function matchEscape(s2) {
90328
+ if (s2[0] !== "\x1B")
90329
+ return { len: 0 };
90330
+ if (s2.length < 2)
90331
+ return null;
90332
+ const c1 = s2[1];
90333
+ if (c1 === "[") {
90334
+ let i2 = 2;
90335
+ while (i2 < s2.length) {
90336
+ const code = s2.charCodeAt(i2);
90337
+ const isDigit = code >= 48 && code <= 57;
90338
+ const isSemicolon = s2[i2] === ";";
90339
+ if (!isDigit && !isSemicolon)
90340
+ break;
90341
+ i2++;
90342
+ }
90343
+ if (i2 >= s2.length)
90344
+ return null;
90345
+ return { len: i2 + 1 };
90346
+ }
90347
+ if (c1 === "O") {
90348
+ if (s2.length < 3)
90349
+ return null;
90350
+ return { len: 3 };
90351
+ }
90352
+ return { len: 2 };
90353
+ }
90354
+ function feedChunk(state, chunk) {
90355
+ let { buf, pending, inPaste } = state;
90356
+ pending = pending + chunk;
90357
+ while (pending.length > 0) {
90358
+ const ch = pending[0];
90359
+ if (ch === ETX) {
90360
+ return { kind: "abort" };
90361
+ }
90362
+ if (ch === "\x1B") {
90363
+ const m2 = matchEscape(pending);
90364
+ if (m2 === null) {
90365
+ return { kind: "continue", state: { buf, pending, inPaste } };
90366
+ }
90367
+ const seq = pending.slice(0, m2.len);
90368
+ pending = pending.slice(m2.len);
90369
+ if (seq === PASTE_START) {
90370
+ inPaste = true;
90371
+ continue;
90372
+ }
90373
+ if (seq === PASTE_END) {
90374
+ inPaste = false;
90375
+ continue;
90376
+ }
90377
+ if (!inPaste && ENTER_CSI.test(seq)) {
90378
+ return { kind: "submit", value: buf };
90379
+ }
90380
+ continue;
90381
+ }
90382
+ pending = pending.slice(1);
90383
+ if (ch === "\r" || ch === `
90384
+ `) {
90385
+ if (!inPaste)
90386
+ return { kind: "submit", value: buf };
90387
+ buf += ch;
90388
+ continue;
90389
+ }
90390
+ if (ch === BACKSPACE || ch === DEL) {
90391
+ if (!inPaste)
90392
+ buf = buf.slice(0, -1);
90393
+ continue;
90394
+ }
90395
+ buf += ch;
90396
+ }
90397
+ return { kind: "continue", state: { buf, pending, inPaste } };
90398
+ }
90164
90399
  function readSecret(promptText) {
90165
90400
  const stdin2 = process.stdin;
90166
90401
  if (stdin2.isTTY === true) {
@@ -90175,7 +90410,7 @@ function readTtySecret(stdin2, promptText) {
90175
90410
  stdin2.setRawMode(true);
90176
90411
  stdin2.resume();
90177
90412
  stdin2.setEncoding("utf8");
90178
- let buf = "";
90413
+ let state = INITIAL_READER_STATE;
90179
90414
  const cleanup = () => {
90180
90415
  stdin2.removeListener("data", onData);
90181
90416
  stdin2.removeListener("error", onError);
@@ -90183,37 +90418,27 @@ function readTtySecret(stdin2, promptText) {
90183
90418
  stdin2.setRawMode(false);
90184
90419
  stdin2.pause();
90185
90420
  };
90186
- const finish = (raw) => {
90187
- cleanup();
90188
- process.stdout.write(`
90421
+ const onData = (chunk) => {
90422
+ const result = feedChunk(state, chunk);
90423
+ if (result.kind === "submit") {
90424
+ cleanup();
90425
+ process.stdout.write(`
90189
90426
  `);
90190
- resolve(sanitizeInput(raw));
90427
+ resolve(result.value);
90428
+ return;
90429
+ }
90430
+ if (result.kind === "abort") {
90431
+ cleanup();
90432
+ process.stdout.write(`
90433
+ `);
90434
+ process.exit(130);
90435
+ }
90436
+ state = result.state;
90191
90437
  };
90192
90438
  const onError = (err) => {
90193
90439
  cleanup();
90194
90440
  reject(err);
90195
90441
  };
90196
- const onData = (chunk) => {
90197
- for (const ch of chunk) {
90198
- if (ch === ETX) {
90199
- if (typeof stdin2.setRawMode === "function")
90200
- stdin2.setRawMode(false);
90201
- process.stdout.write(`
90202
- `);
90203
- process.exit(130);
90204
- }
90205
- if (ch === "\r" || ch === `
90206
- `) {
90207
- finish(buf);
90208
- return;
90209
- }
90210
- if (ch === BACKSPACE || ch === DEL) {
90211
- buf = buf.slice(0, -1);
90212
- continue;
90213
- }
90214
- buf += ch;
90215
- }
90216
- };
90217
90442
  stdin2.on("data", onData);
90218
90443
  stdin2.on("error", onError);
90219
90444
  });
@@ -90393,8 +90618,9 @@ async function runLogin(workspaceSlug, apiBaseUrl, deps = {}) {
90393
90618
 
90394
90619
  `);
90395
90620
  openFn(settingsUrl);
90396
- const pat = (await readFn("Paste your token here (input hidden): ")).trim();
90397
- if (!isPlausiblePat(pat)) {
90621
+ const raw = await readFn("Paste your token here (nothing will appear — paste, then press Enter): ");
90622
+ const pat = extractPat(raw);
90623
+ if (pat === null) {
90398
90624
  err.write(`Error: that does not look like a Plinth personal access token.
90399
90625
  `);
90400
90626
  return 1;
@@ -90463,6 +90689,94 @@ var loginCommand = defineCommand({
90463
90689
  }
90464
90690
  });
90465
90691
 
90692
+ // src/commands/start.ts
90693
+ import { statSync as statSync4 } from "node:fs";
90694
+
90695
+ // src/lib/mirror.ts
90696
+ import {
90697
+ lstatSync,
90698
+ mkdirSync as mkdirSync2,
90699
+ renameSync as renameSync2,
90700
+ unlinkSync as unlinkSync2,
90701
+ writeFileSync as writeFileSync2
90702
+ } from "node:fs";
90703
+ import { dirname as dirname2, join as join2, resolve, sep as sep2 } from "node:path";
90704
+ var SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
90705
+
90706
+ class MirrorPathError extends Error {
90707
+ constructor(message) {
90708
+ super(message);
90709
+ this.name = "MirrorPathError";
90710
+ }
90711
+ }
90712
+ function assertSlug(label, slug) {
90713
+ if (!SLUG_RE.test(slug) || slug.length > 200) {
90714
+ throw new MirrorPathError(`Invalid ${label}: ${JSON.stringify(slug)}`);
90715
+ }
90716
+ }
90717
+ function assertNotSymlink(path) {
90718
+ try {
90719
+ const stat = lstatSync(path);
90720
+ if (stat.isSymbolicLink()) {
90721
+ throw new MirrorPathError(`Refusing to follow symlink at ${path}. ` + `Remove it and re-run plinth sync.`);
90722
+ }
90723
+ } catch (err) {
90724
+ if (err?.code === "ENOENT")
90725
+ return;
90726
+ if (err instanceof MirrorPathError)
90727
+ throw err;
90728
+ throw err;
90729
+ }
90730
+ }
90731
+ function workspaceMirrorPath(workspaceSlug) {
90732
+ assertSlug("workspace slug", workspaceSlug);
90733
+ const root = mirrorRoot();
90734
+ const ws = join2(root, workspaceSlug);
90735
+ const resolved = resolve(ws);
90736
+ if (resolved !== resolve(root, workspaceSlug)) {
90737
+ throw new MirrorPathError(`Resolved workspace path escapes mirror root`);
90738
+ }
90739
+ assertNotSymlink(resolved);
90740
+ return resolved;
90741
+ }
90742
+ function entityFilePath(workspaceSlug, entityType, entitySlug, ext = "md") {
90743
+ assertSlug("entity type", entityType);
90744
+ assertSlug("entity slug", entitySlug);
90745
+ const ws = workspaceMirrorPath(workspaceSlug);
90746
+ const target = join2(ws, entityType, `${entitySlug}.${ext}`);
90747
+ const resolved = resolve(target);
90748
+ const prefix = ws.endsWith(sep2) ? ws : ws + sep2;
90749
+ if (!resolved.startsWith(prefix)) {
90750
+ throw new MirrorPathError(`Resolved entity path escapes workspace folder`);
90751
+ }
90752
+ return resolved;
90753
+ }
90754
+ function writeMirrorFile(workspaceSlug, entityType, entitySlug, content, ext = "md") {
90755
+ const target = entityFilePath(workspaceSlug, entityType, entitySlug, ext);
90756
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
90757
+ mkdirSync2(dirname2(target), { recursive: true, mode: 448 });
90758
+ writeFileSync2(tmp, content, { mode: 384 });
90759
+ try {
90760
+ renameSync2(tmp, target);
90761
+ } catch (err) {
90762
+ try {
90763
+ unlinkSync2(tmp);
90764
+ } catch {}
90765
+ throw err;
90766
+ }
90767
+ }
90768
+ function deleteMirrorFile(workspaceSlug, entityType, entitySlug, ext = "md") {
90769
+ const target = entityFilePath(workspaceSlug, entityType, entitySlug, ext);
90770
+ try {
90771
+ unlinkSync2(target);
90772
+ return true;
90773
+ } catch (err) {
90774
+ if (err?.code === "ENOENT")
90775
+ return false;
90776
+ throw err;
90777
+ }
90778
+ }
90779
+
90466
90780
  // node_modules/chalk/source/vendor/ansi-styles/index.js
90467
90781
  var ANSI_BACKGROUND_OFFSET = 10;
90468
90782
  var wrapAnsi16 = (offset = 0) => (code) => `\x1B[${code + offset}m`;
@@ -93906,118 +94220,53 @@ class Output {
93906
94220
  }
93907
94221
  }
93908
94222
 
94223
+ // src/lib/per-id-mutex.ts
94224
+ class PerIdMutex {
94225
+ chains = new Map;
94226
+ async run(key, fn) {
94227
+ const prior = this.chains.get(key) ?? Promise.resolve();
94228
+ const next = prior.then(fn, fn);
94229
+ this.chains.set(key, next);
94230
+ const cleanup = () => {
94231
+ if (this.chains.get(key) === next) {
94232
+ this.chains.delete(key);
94233
+ }
94234
+ };
94235
+ next.then(cleanup, cleanup);
94236
+ return next;
94237
+ }
94238
+ size() {
94239
+ return this.chains.size;
94240
+ }
94241
+ }
94242
+
93909
94243
  // src/lib/push.ts
93910
94244
  var import_gray_matter2 = __toESM(require_gray_matter(), 1);
93911
94245
  import { createHash } from "node:crypto";
93912
- import { existsSync, readFileSync as readFileSync3 } from "node:fs";
93913
- import { relative } from "node:path";
94246
+ import { readFileSync as readFileSync3, statSync } from "node:fs";
94247
+ import { basename, relative } from "node:path";
93914
94248
 
93915
94249
  // src/lib/conflicts.ts
93916
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
93917
- import { dirname as dirname2, join as join2 } from "node:path";
94250
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
94251
+ import { dirname as dirname3, join as join3 } from "node:path";
93918
94252
  function conflictsRoot(workspaceSlug) {
93919
- return join2(plinthHome(), "_conflicts", workspaceSlug);
94253
+ return join3(plinthHome(), "_conflicts", workspaceSlug);
93920
94254
  }
93921
94255
  function conflictFilePath(args) {
93922
94256
  const { workspaceSlug, entityType, entitySlug, timestamp } = args;
93923
94257
  const iso = timestamp.toISOString().replace(/[:.]/g, "-");
93924
- return join2(conflictsRoot(workspaceSlug), `${entityType}.${entitySlug}.${iso}.md`);
94258
+ return join3(conflictsRoot(workspaceSlug), `${entityType}.${entitySlug}.${iso}.md`);
93925
94259
  }
93926
94260
  function stashConflict(args) {
93927
94261
  const target = conflictFilePath(args);
93928
- mkdirSync2(dirname2(target), { recursive: true, mode: 448 });
93929
- writeFileSync2(target, args.contents, { mode: 384 });
94262
+ mkdirSync3(dirname3(target), { recursive: true, mode: 448 });
94263
+ writeFileSync3(target, args.contents, { mode: 384 });
93930
94264
  return target;
93931
94265
  }
93932
94266
 
93933
94267
  // src/lib/push.ts
93934
94268
  init_markdown();
93935
94269
 
93936
- // src/lib/mirror.ts
93937
- import {
93938
- lstatSync,
93939
- mkdirSync as mkdirSync3,
93940
- renameSync as renameSync2,
93941
- unlinkSync as unlinkSync2,
93942
- writeFileSync as writeFileSync3
93943
- } from "node:fs";
93944
- import { dirname as dirname3, join as join3, resolve, sep as sep2 } from "node:path";
93945
- var SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
93946
-
93947
- class MirrorPathError extends Error {
93948
- constructor(message) {
93949
- super(message);
93950
- this.name = "MirrorPathError";
93951
- }
93952
- }
93953
- function assertSlug(label2, slug) {
93954
- if (!SLUG_RE.test(slug) || slug.length > 200) {
93955
- throw new MirrorPathError(`Invalid ${label2}: ${JSON.stringify(slug)}`);
93956
- }
93957
- }
93958
- function assertNotSymlink(path) {
93959
- try {
93960
- const stat = lstatSync(path);
93961
- if (stat.isSymbolicLink()) {
93962
- throw new MirrorPathError(`Refusing to follow symlink at ${path}. ` + `Remove it and re-run plinth sync.`);
93963
- }
93964
- } catch (err) {
93965
- if (err?.code === "ENOENT")
93966
- return;
93967
- if (err instanceof MirrorPathError)
93968
- throw err;
93969
- throw err;
93970
- }
93971
- }
93972
- function workspaceMirrorPath(workspaceSlug) {
93973
- assertSlug("workspace slug", workspaceSlug);
93974
- const root2 = mirrorRoot();
93975
- const ws = join3(root2, workspaceSlug);
93976
- const resolved = resolve(ws);
93977
- if (resolved !== resolve(root2, workspaceSlug)) {
93978
- throw new MirrorPathError(`Resolved workspace path escapes mirror root`);
93979
- }
93980
- assertNotSymlink(resolved);
93981
- return resolved;
93982
- }
93983
- function entityFilePath(workspaceSlug, entityType, entitySlug, ext = "md") {
93984
- assertSlug("entity type", entityType);
93985
- assertSlug("entity slug", entitySlug);
93986
- const ws = workspaceMirrorPath(workspaceSlug);
93987
- const target2 = join3(ws, entityType, `${entitySlug}.${ext}`);
93988
- const resolved = resolve(target2);
93989
- const prefix2 = ws.endsWith(sep2) ? ws : ws + sep2;
93990
- if (!resolved.startsWith(prefix2)) {
93991
- throw new MirrorPathError(`Resolved entity path escapes workspace folder`);
93992
- }
93993
- return resolved;
93994
- }
93995
- function writeMirrorFile(workspaceSlug, entityType, entitySlug, content2, ext = "md") {
93996
- const target2 = entityFilePath(workspaceSlug, entityType, entitySlug, ext);
93997
- const tmp = `${target2}.${process.pid}.${Date.now()}.tmp`;
93998
- mkdirSync3(dirname3(target2), { recursive: true, mode: 448 });
93999
- writeFileSync3(tmp, content2, { mode: 384 });
94000
- try {
94001
- renameSync2(tmp, target2);
94002
- } catch (err) {
94003
- try {
94004
- unlinkSync2(tmp);
94005
- } catch {}
94006
- throw err;
94007
- }
94008
- }
94009
- function deleteMirrorFile(workspaceSlug, entityType, entitySlug, ext = "md") {
94010
- const target2 = entityFilePath(workspaceSlug, entityType, entitySlug, ext);
94011
- try {
94012
- unlinkSync2(target2);
94013
- return true;
94014
- } catch (err) {
94015
- if (err?.code === "ENOENT")
94016
- return false;
94017
- throw err;
94018
- }
94019
- }
94020
-
94021
94270
  // src/lib/state.ts
94022
94271
  import {
94023
94272
  mkdirSync as mkdirSync4,
@@ -94033,7 +94282,8 @@ var StateEntrySchema = exports_external.object({
94033
94282
  updated_at: exports_external.string().datetime({ offset: true }),
94034
94283
  revision: exports_external.number().int().nonnegative(),
94035
94284
  body_hash: exports_external.string().min(1),
94036
- frontmatter_hash: exports_external.string().min(1)
94285
+ frontmatter_hash: exports_external.string().min(1),
94286
+ inode: exports_external.number().int().nonnegative().optional()
94037
94287
  });
94038
94288
  var WorkspaceStateSchema = exports_external.object({
94039
94289
  version: exports_external.literal(1),
@@ -94086,6 +94336,9 @@ function upsertEntry(state2, entry2) {
94086
94336
  };
94087
94337
  return { state: next, changed: true };
94088
94338
  }
94339
+ function getEntry(state2, id2) {
94340
+ return state2.documents[id2];
94341
+ }
94089
94342
  function getEntryBySlug(state2, slug) {
94090
94343
  for (const entry2 of Object.values(state2.documents)) {
94091
94344
  if (entry2.slug === slug)
@@ -94094,7 +94347,7 @@ function getEntryBySlug(state2, slug) {
94094
94347
  return;
94095
94348
  }
94096
94349
  function entriesEqual(a2, b3) {
94097
- return a2.id === b3.id && a2.slug === b3.slug && a2.updated_at === b3.updated_at && a2.revision === b3.revision && a2.body_hash === b3.body_hash && a2.frontmatter_hash === b3.frontmatter_hash;
94350
+ return a2.id === b3.id && a2.slug === b3.slug && a2.updated_at === b3.updated_at && a2.revision === b3.revision && a2.body_hash === b3.body_hash && a2.frontmatter_hash === b3.frontmatter_hash && a2.inode === b3.inode;
94098
94351
  }
94099
94352
 
94100
94353
  // src/lib/push.ts
@@ -94151,30 +94404,120 @@ function classifyMirrorPath(args) {
94151
94404
  }
94152
94405
  return { entityType, entitySlug };
94153
94406
  }
94407
+ function resolveTitle(parsedBody, fm, slug) {
94408
+ const lines = parsedBody.split(`
94409
+ `);
94410
+ for (const line of lines) {
94411
+ const m3 = line.match(/^#\s+(.+?)\s*$/);
94412
+ if (m3 && m3[1]?.length)
94413
+ return m3[1];
94414
+ }
94415
+ if (typeof fm.title === "string" && fm.title.length > 0)
94416
+ return fm.title;
94417
+ return humaniseSlug(slug);
94418
+ }
94419
+ function humaniseSlug(slug) {
94420
+ return slug.split("-").filter((part) => part.length > 0).map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
94421
+ }
94422
+ function safeInode(absolutePath) {
94423
+ try {
94424
+ return statSync(absolutePath).ino;
94425
+ } catch {
94426
+ return;
94427
+ }
94428
+ }
94429
+ function persistEntry(state2, entry2) {
94430
+ const { state: nextState, changed } = upsertEntry(state2, entry2);
94431
+ if (changed)
94432
+ writeState(nextState);
94433
+ return nextState;
94434
+ }
94435
+ function dropEntry(state2, id2) {
94436
+ if (!(id2 in state2.documents))
94437
+ return state2;
94438
+ const nextDocs = { ...state2.documents };
94439
+ delete nextDocs[id2];
94440
+ const next = { ...state2, documents: nextDocs };
94441
+ writeState(next);
94442
+ return next;
94443
+ }
94444
+ async function pullAndWriteMirror(args) {
94445
+ const cloud = await getDocument({
94446
+ baseUrl: args.baseUrl,
94447
+ pat: args.pat,
94448
+ id: args.id,
94449
+ fetchImpl: args.fetchImpl
94450
+ });
94451
+ const { serialiseDocument: serialiseDocument2 } = await Promise.resolve().then(() => (init_markdown(), exports_markdown));
94452
+ writeMirrorFile(args.workspaceSlug, args.entityType, cloud.slug, serialiseDocument2(cloud));
94453
+ const mirrorPath = entityFilePath(args.workspaceSlug, args.entityType, cloud.slug);
94454
+ const mirrorRaw = readFileSync3(mirrorPath, "utf8");
94455
+ const mirrorParsed = import_gray_matter2.default(mirrorRaw);
94456
+ const refreshed = {
94457
+ id: cloud.id,
94458
+ slug: cloud.slug,
94459
+ updated_at: cloud.updated_at,
94460
+ revision: cloud.revision,
94461
+ body_hash: stableHash(mirrorParsed.content),
94462
+ frontmatter_hash: stableHash(userFrontmatter(mirrorParsed.data ?? {})),
94463
+ inode: safeInode(mirrorPath)
94464
+ };
94465
+ return persistEntry(args.state, refreshed);
94466
+ }
94154
94467
  async function pushFile(args) {
94155
- const { workspaceSlug, baseUrl, pat, absolutePath, entityType, state: state2, exists, deps } = args;
94468
+ const {
94469
+ workspaceSlug,
94470
+ baseUrl,
94471
+ pat,
94472
+ absolutePath,
94473
+ entityType,
94474
+ state: state2,
94475
+ exists,
94476
+ unlinkContext,
94477
+ deps
94478
+ } = args;
94156
94479
  const fetchImpl = deps?.fetchImpl;
94157
94480
  const now = deps?.now ?? (() => new Date);
94481
+ const mutex = deps?.mutex ?? new PerIdMutex;
94158
94482
  if (entityType !== "documents") {
94159
94483
  return {
94160
- outcome: { kind: "ignored", absolutePath, reason: `entity type ${entityType} not yet pushed` },
94484
+ outcome: {
94485
+ kind: "ignored",
94486
+ absolutePath,
94487
+ reason: `entity type ${entityType} not yet pushed`
94488
+ },
94161
94489
  state: state2
94162
94490
  };
94163
94491
  }
94164
94492
  if (!exists) {
94165
- if (!existsSync(absolutePath)) {
94166
- return {
94167
- outcome: { kind: "deferred_delete", absolutePath },
94168
- state: state2
94169
- };
94170
- }
94493
+ return runPushDelete({
94494
+ workspaceSlug,
94495
+ entityType,
94496
+ baseUrl,
94497
+ pat,
94498
+ absolutePath,
94499
+ state: state2,
94500
+ unlinkContext,
94501
+ mutex,
94502
+ fetchImpl
94503
+ });
94171
94504
  }
94172
94505
  let raw;
94173
94506
  try {
94174
94507
  raw = readFileSync3(absolutePath, "utf8");
94175
94508
  } catch (err) {
94176
94509
  if (err?.code === "ENOENT") {
94177
- return { outcome: { kind: "deferred_delete", absolutePath }, state: state2 };
94510
+ return runPushDelete({
94511
+ workspaceSlug,
94512
+ entityType,
94513
+ baseUrl,
94514
+ pat,
94515
+ absolutePath,
94516
+ state: state2,
94517
+ unlinkContext: null,
94518
+ mutex,
94519
+ fetchImpl
94520
+ });
94178
94521
  }
94179
94522
  throw err;
94180
94523
  }
@@ -94189,100 +94532,404 @@ async function pushFile(args) {
94189
94532
  }
94190
94533
  const fm = parsed.data ?? {};
94191
94534
  const slugFromFm = typeof fm.slug === "string" && fm.slug.length > 0 ? fm.slug : null;
94192
- if (!slugFromFm) {
94535
+ const slugFromPath = basename(absolutePath, ".md");
94536
+ const slug = slugFromFm ?? slugFromPath;
94537
+ if (slug.length === 0) {
94193
94538
  return {
94194
- outcome: { kind: "deferred_new_file", absolutePath },
94539
+ outcome: { kind: "ignored", absolutePath, reason: "empty slug" },
94195
94540
  state: state2
94196
94541
  };
94197
94542
  }
94198
- const stateEntry = getEntryBySlug(state2, slugFromFm);
94543
+ const stateEntry = getEntryBySlug(state2, slug);
94199
94544
  if (!stateEntry) {
94200
- return {
94201
- outcome: { kind: "deferred_new_file", absolutePath },
94202
- state: state2
94203
- };
94545
+ return runPushCreate({
94546
+ workspaceSlug,
94547
+ entityType,
94548
+ baseUrl,
94549
+ pat,
94550
+ absolutePath,
94551
+ slug,
94552
+ raw,
94553
+ parsed,
94554
+ state: state2,
94555
+ mutex,
94556
+ fetchImpl,
94557
+ now
94558
+ });
94204
94559
  }
94205
- const userFm = userFrontmatter(fm);
94206
- const bodyMarkdown = parsed.content;
94207
- const newBodyHash = stableHash(bodyMarkdown);
94208
- const newFrontmatterHash = stableHash(userFm);
94209
- const bodyChanged = newBodyHash !== stateEntry.body_hash;
94210
- const frontmatterChanged = newFrontmatterHash !== stateEntry.frontmatter_hash;
94211
- if (!bodyChanged && !frontmatterChanged) {
94212
- return { outcome: { kind: "noop", entitySlug: slugFromFm }, state: state2 };
94213
- }
94214
- const html = bodyChanged ? markdownToHtml(bodyMarkdown) : undefined;
94215
- const patchResult = await patchDocument({
94560
+ return runPushUpsert({
94561
+ workspaceSlug,
94562
+ entityType,
94216
94563
  baseUrl,
94217
94564
  pat,
94218
- id: stateEntry.id,
94219
- expectedUpdatedAt: stateEntry.updated_at,
94220
- body: html,
94221
- frontmatter: frontmatterChanged ? userFm : undefined,
94222
- fetchImpl
94565
+ absolutePath,
94566
+ raw,
94567
+ parsed,
94568
+ stateEntry,
94569
+ state: state2,
94570
+ mutex,
94571
+ fetchImpl,
94572
+ now
94223
94573
  });
94224
- if (patchResult.ok) {
94225
- const updated = {
94226
- id: patchResult.doc.id,
94227
- slug: patchResult.doc.slug,
94228
- updated_at: patchResult.doc.updated_at,
94229
- revision: patchResult.doc.revision,
94230
- body_hash: newBodyHash,
94231
- frontmatter_hash: newFrontmatterHash
94574
+ }
94575
+ async function runPushUpsert(args) {
94576
+ return args.mutex.run(args.stateEntry.id, async () => {
94577
+ const fm = args.parsed.data ?? {};
94578
+ const userFm = userFrontmatter(fm);
94579
+ const bodyMarkdown = args.parsed.content;
94580
+ const newBodyHash = stableHash(bodyMarkdown);
94581
+ const newFrontmatterHash = stableHash(userFm);
94582
+ const liveEntry = getEntry(args.state, args.stateEntry.id) ?? args.stateEntry;
94583
+ const bodyChanged = newBodyHash !== liveEntry.body_hash;
94584
+ const frontmatterChanged = newFrontmatterHash !== liveEntry.frontmatter_hash;
94585
+ if (!bodyChanged && !frontmatterChanged) {
94586
+ return { outcome: { kind: "noop", entitySlug: liveEntry.slug }, state: args.state };
94587
+ }
94588
+ const html = bodyChanged ? markdownToHtml(bodyMarkdown) : undefined;
94589
+ const patchResult = await patchDocument({
94590
+ baseUrl: args.baseUrl,
94591
+ pat: args.pat,
94592
+ id: liveEntry.id,
94593
+ expectedUpdatedAt: liveEntry.updated_at,
94594
+ body: html,
94595
+ frontmatter: frontmatterChanged ? userFm : undefined,
94596
+ fetchImpl: args.fetchImpl
94597
+ });
94598
+ if (patchResult.ok) {
94599
+ const updated = {
94600
+ id: patchResult.doc.id,
94601
+ slug: patchResult.doc.slug,
94602
+ updated_at: patchResult.doc.updated_at,
94603
+ revision: patchResult.doc.revision,
94604
+ body_hash: newBodyHash,
94605
+ frontmatter_hash: newFrontmatterHash,
94606
+ inode: safeInode(args.absolutePath)
94607
+ };
94608
+ const nextState2 = persistEntry(args.state, updated);
94609
+ return {
94610
+ outcome: { kind: "pushed", entitySlug: updated.slug },
94611
+ state: nextState2
94612
+ };
94613
+ }
94614
+ if (patchResult.code === 404) {
94615
+ return {
94616
+ outcome: { kind: "not_found", entitySlug: liveEntry.slug },
94617
+ state: args.state
94618
+ };
94619
+ }
94620
+ if (patchResult.reason === "slug_conflict") {
94621
+ throw new Error("patchDocument returned slug_conflict for a body+frontmatter PATCH (no slug sent)");
94622
+ }
94623
+ const stashPath = stashConflict({
94624
+ workspaceSlug: args.workspaceSlug,
94625
+ entityType: args.entityType,
94626
+ entitySlug: liveEntry.slug,
94627
+ timestamp: args.now(),
94628
+ contents: args.raw
94629
+ });
94630
+ const nextState = await pullAndWriteMirror({
94631
+ workspaceSlug: args.workspaceSlug,
94632
+ entityType: args.entityType,
94633
+ baseUrl: args.baseUrl,
94634
+ pat: args.pat,
94635
+ id: liveEntry.id,
94636
+ state: args.state,
94637
+ fetchImpl: args.fetchImpl
94638
+ });
94639
+ return {
94640
+ outcome: { kind: "conflict", entitySlug: liveEntry.slug, stashPath },
94641
+ state: nextState
94232
94642
  };
94233
- const { state: nextState2, changed } = upsertEntry(state2, updated);
94234
- if (changed)
94235
- writeState(nextState2);
94643
+ });
94644
+ }
94645
+ async function runPushCreate(args) {
94646
+ const key = `create:${args.entityType}:${args.slug}`;
94647
+ return args.mutex.run(key, async () => {
94648
+ const inLock = getEntryBySlug(args.state, args.slug);
94649
+ if (inLock) {
94650
+ return runPushUpsert({
94651
+ workspaceSlug: args.workspaceSlug,
94652
+ entityType: args.entityType,
94653
+ baseUrl: args.baseUrl,
94654
+ pat: args.pat,
94655
+ absolutePath: args.absolutePath,
94656
+ raw: args.raw,
94657
+ parsed: args.parsed,
94658
+ stateEntry: inLock,
94659
+ state: args.state,
94660
+ mutex: args.mutex,
94661
+ fetchImpl: args.fetchImpl,
94662
+ now: args.now
94663
+ });
94664
+ }
94665
+ const fm = args.parsed.data ?? {};
94666
+ const userFm = userFrontmatter(fm);
94667
+ if ("title" in userFm)
94668
+ delete userFm.title;
94669
+ const title = resolveTitle(args.parsed.content, fm, args.slug);
94670
+ const htmlBody = markdownToHtml(args.parsed.content);
94671
+ const artistId = isOptionalUuid(fm.artist_id);
94672
+ const projectId = isOptionalUuid(fm.project_id);
94673
+ const createResult = await createDocument({
94674
+ baseUrl: args.baseUrl,
94675
+ pat: args.pat,
94676
+ slug: args.slug,
94677
+ title,
94678
+ body: htmlBody,
94679
+ frontmatter: userFm,
94680
+ artist_id: artistId,
94681
+ project_id: projectId,
94682
+ fetchImpl: args.fetchImpl
94683
+ });
94684
+ if (createResult.ok) {
94685
+ const doc2 = createResult.doc;
94686
+ const nextState2 = persistEntry(args.state, {
94687
+ id: doc2.id,
94688
+ slug: doc2.slug,
94689
+ updated_at: doc2.updated_at,
94690
+ revision: doc2.revision,
94691
+ body_hash: stableHash(args.parsed.content),
94692
+ frontmatter_hash: stableHash(userFm),
94693
+ inode: safeInode(args.absolutePath)
94694
+ });
94695
+ return {
94696
+ outcome: { kind: "created", entitySlug: doc2.slug },
94697
+ state: nextState2
94698
+ };
94699
+ }
94700
+ const stashPath = stashConflict({
94701
+ workspaceSlug: args.workspaceSlug,
94702
+ entityType: args.entityType,
94703
+ entitySlug: args.slug,
94704
+ timestamp: args.now(),
94705
+ contents: args.raw
94706
+ });
94707
+ const nextState = await pullAndWriteMirror({
94708
+ workspaceSlug: args.workspaceSlug,
94709
+ entityType: args.entityType,
94710
+ baseUrl: args.baseUrl,
94711
+ pat: args.pat,
94712
+ id: createResult.existing.id,
94713
+ state: args.state,
94714
+ fetchImpl: args.fetchImpl
94715
+ });
94236
94716
  return {
94237
- outcome: { kind: "pushed", entitySlug: slugFromFm },
94238
- state: nextState2
94717
+ outcome: {
94718
+ kind: "create_slug_conflict",
94719
+ entitySlug: args.slug,
94720
+ stashPath
94721
+ },
94722
+ state: nextState
94239
94723
  };
94724
+ });
94725
+ }
94726
+ async function runPushDelete(args) {
94727
+ let id2;
94728
+ let slug;
94729
+ let expectedUpdatedAt;
94730
+ if (args.unlinkContext) {
94731
+ id2 = args.unlinkContext.id;
94732
+ slug = args.unlinkContext.slug;
94733
+ expectedUpdatedAt = args.unlinkContext.updated_at;
94734
+ } else {
94735
+ const slugFromPath = basename(args.absolutePath, ".md");
94736
+ const entry2 = getEntryBySlug(args.state, slugFromPath);
94737
+ if (entry2) {
94738
+ id2 = entry2.id;
94739
+ slug = entry2.slug;
94740
+ expectedUpdatedAt = entry2.updated_at;
94741
+ }
94240
94742
  }
94241
- if (patchResult.code === 404) {
94743
+ if (!id2 || !slug || !expectedUpdatedAt) {
94242
94744
  return {
94243
- outcome: { kind: "not_found", entitySlug: slugFromFm },
94244
- state: state2
94745
+ outcome: {
94746
+ kind: "ignored",
94747
+ absolutePath: args.absolutePath,
94748
+ reason: "untracked file — no cloud row to delete"
94749
+ },
94750
+ state: args.state
94245
94751
  };
94246
94752
  }
94247
- const stashPath = stashConflict({
94248
- workspaceSlug,
94249
- entityType,
94250
- entitySlug: slugFromFm,
94251
- timestamp: now(),
94252
- contents: raw
94753
+ return args.mutex.run(id2, async () => {
94754
+ const live = getEntry(args.state, id2) ?? null;
94755
+ const effectiveExpected = live?.updated_at ?? expectedUpdatedAt;
94756
+ const result2 = await deleteDocument({
94757
+ baseUrl: args.baseUrl,
94758
+ pat: args.pat,
94759
+ id: id2,
94760
+ expectedUpdatedAt: effectiveExpected,
94761
+ fetchImpl: args.fetchImpl
94762
+ });
94763
+ if (result2.ok) {
94764
+ const nextState2 = dropEntry(args.state, id2);
94765
+ return {
94766
+ outcome: { kind: "deleted", entitySlug: slug },
94767
+ state: nextState2
94768
+ };
94769
+ }
94770
+ if (result2.code === 404) {
94771
+ const nextState2 = dropEntry(args.state, id2);
94772
+ return {
94773
+ outcome: { kind: "deleted", entitySlug: slug },
94774
+ state: nextState2
94775
+ };
94776
+ }
94777
+ const nextState = await pullAndWriteMirror({
94778
+ workspaceSlug: args.workspaceSlug,
94779
+ entityType: args.entityType,
94780
+ baseUrl: args.baseUrl,
94781
+ pat: args.pat,
94782
+ id: id2,
94783
+ state: args.state,
94784
+ fetchImpl: args.fetchImpl
94785
+ });
94786
+ return {
94787
+ outcome: { kind: "conflict", entitySlug: slug, stashPath: "" },
94788
+ state: nextState
94789
+ };
94253
94790
  });
94254
- const cloud = await getDocument({
94791
+ }
94792
+ async function pushRename(args) {
94793
+ const {
94794
+ workspaceSlug,
94255
94795
  baseUrl,
94256
94796
  pat,
94257
- id: stateEntry.id,
94258
- fetchImpl
94797
+ fromPath,
94798
+ toPath,
94799
+ id: id2,
94800
+ expectedUpdatedAt,
94801
+ fromSlug,
94802
+ state: state2,
94803
+ deps
94804
+ } = args;
94805
+ const fetchImpl = deps?.fetchImpl;
94806
+ const now = deps?.now ?? (() => new Date);
94807
+ const mutex = deps?.mutex ?? new PerIdMutex;
94808
+ const entityType = "documents";
94809
+ const toSlug = basename(toPath, ".md");
94810
+ return mutex.run(id2, async () => {
94811
+ const live = getEntry(state2, id2);
94812
+ const effectiveExpected = live?.updated_at ?? expectedUpdatedAt;
94813
+ const patchResult = await patchDocument({
94814
+ baseUrl,
94815
+ pat,
94816
+ id: id2,
94817
+ expectedUpdatedAt: effectiveExpected,
94818
+ slug: toSlug,
94819
+ fetchImpl
94820
+ });
94821
+ if (patchResult.ok) {
94822
+ const doc2 = patchResult.doc;
94823
+ const prior = live ?? null;
94824
+ const updated = {
94825
+ id: doc2.id,
94826
+ slug: doc2.slug,
94827
+ updated_at: doc2.updated_at,
94828
+ revision: doc2.revision,
94829
+ body_hash: prior?.body_hash ?? "",
94830
+ frontmatter_hash: prior?.frontmatter_hash ?? "",
94831
+ inode: safeInode(toPath)
94832
+ };
94833
+ if (!updated.body_hash || !updated.frontmatter_hash) {
94834
+ try {
94835
+ const mirrorRaw = readFileSync3(toPath, "utf8");
94836
+ const mirrorParsed = import_gray_matter2.default(mirrorRaw);
94837
+ updated.body_hash = stableHash(mirrorParsed.content);
94838
+ updated.frontmatter_hash = stableHash(userFrontmatter(mirrorParsed.data ?? {}));
94839
+ } catch {}
94840
+ }
94841
+ const nextState2 = persistEntry(state2, updated);
94842
+ return {
94843
+ outcome: { kind: "renamed", fromSlug, toSlug: doc2.slug },
94844
+ state: nextState2
94845
+ };
94846
+ }
94847
+ if (patchResult.code === 404) {
94848
+ return {
94849
+ outcome: { kind: "not_found", entitySlug: fromSlug },
94850
+ state: state2
94851
+ };
94852
+ }
94853
+ if (patchResult.reason === "slug_conflict") {
94854
+ let contents2 = "";
94855
+ try {
94856
+ contents2 = readFileSync3(toPath, "utf8");
94857
+ } catch {}
94858
+ const stashPath2 = stashConflict({
94859
+ workspaceSlug,
94860
+ entityType,
94861
+ entitySlug: fromSlug,
94862
+ timestamp: now(),
94863
+ contents: contents2
94864
+ });
94865
+ const afterColliding = await pullAndWriteMirror({
94866
+ workspaceSlug,
94867
+ entityType,
94868
+ baseUrl,
94869
+ pat,
94870
+ id: patchResult.existing.id,
94871
+ state: state2,
94872
+ fetchImpl
94873
+ });
94874
+ const finalState = await pullAndWriteMirror({
94875
+ workspaceSlug,
94876
+ entityType,
94877
+ baseUrl,
94878
+ pat,
94879
+ id: id2,
94880
+ state: afterColliding,
94881
+ fetchImpl
94882
+ });
94883
+ return {
94884
+ outcome: {
94885
+ kind: "rename_slug_conflict",
94886
+ fromSlug,
94887
+ toSlug,
94888
+ stashPath: stashPath2
94889
+ },
94890
+ state: finalState
94891
+ };
94892
+ }
94893
+ let contents = "";
94894
+ try {
94895
+ contents = readFileSync3(toPath, "utf8");
94896
+ } catch {}
94897
+ const stashPath = stashConflict({
94898
+ workspaceSlug,
94899
+ entityType,
94900
+ entitySlug: fromSlug,
94901
+ timestamp: now(),
94902
+ contents
94903
+ });
94904
+ const nextState = await pullAndWriteMirror({
94905
+ workspaceSlug,
94906
+ entityType,
94907
+ baseUrl,
94908
+ pat,
94909
+ id: id2,
94910
+ state: state2,
94911
+ fetchImpl
94912
+ });
94913
+ return {
94914
+ outcome: { kind: "conflict", entitySlug: fromSlug, stashPath },
94915
+ state: nextState
94916
+ };
94259
94917
  });
94260
- const { serialiseDocument: serialiseDocument2 } = await Promise.resolve().then(() => (init_markdown(), exports_markdown));
94261
- writeMirrorFile(workspaceSlug, entityType, cloud.slug, serialiseDocument2(cloud));
94262
- const refreshed = {
94263
- id: cloud.id,
94264
- slug: cloud.slug,
94265
- updated_at: cloud.updated_at,
94266
- revision: cloud.revision,
94267
- body_hash: "",
94268
- frontmatter_hash: ""
94269
- };
94270
- const mirrorPath = entityFilePath(workspaceSlug, entityType, cloud.slug);
94271
- const mirrorRaw = readFileSync3(mirrorPath, "utf8");
94272
- const mirrorParsed = import_gray_matter2.default(mirrorRaw);
94273
- refreshed.body_hash = stableHash(mirrorParsed.content);
94274
- refreshed.frontmatter_hash = stableHash(userFrontmatter(mirrorParsed.data ?? {}));
94275
- const { state: nextState } = upsertEntry(state2, refreshed);
94276
- writeState(nextState);
94277
- return {
94278
- outcome: { kind: "conflict", entitySlug: slugFromFm, stashPath },
94279
- state: nextState
94280
- };
94918
+ }
94919
+ function isOptionalUuid(value2) {
94920
+ if (value2 === null)
94921
+ return null;
94922
+ if (typeof value2 !== "string")
94923
+ return;
94924
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value2)) {
94925
+ return value2;
94926
+ }
94927
+ return;
94281
94928
  }
94282
94929
 
94283
94930
  // src/commands/sync.ts
94284
94931
  var import_gray_matter3 = __toESM(require_gray_matter(), 1);
94285
- import { existsSync as existsSync2, readdirSync } from "node:fs";
94932
+ import { existsSync as existsSync2, readdirSync, statSync as statSync2 } from "node:fs";
94286
94933
  init_markdown();
94287
94934
  var ENTITY_TYPE = "documents";
94288
94935
  function hasExistingContent(workspacePath) {
@@ -94404,13 +95051,20 @@ function applyDocument(workspaceSlug, doc2, state2) {
94404
95051
  writeMirrorFile(workspaceSlug, ENTITY_TYPE, doc2.slug, content2);
94405
95052
  const parsed = import_gray_matter3.default(content2);
94406
95053
  const userFm = userFrontmatter(parsed.data ?? {});
95054
+ let inode;
95055
+ try {
95056
+ inode = statSync2(entityFilePath(workspaceSlug, ENTITY_TYPE, doc2.slug)).ino;
95057
+ } catch {
95058
+ inode = undefined;
95059
+ }
94407
95060
  const { state: nextState } = upsertEntry(state2, {
94408
95061
  id: doc2.id,
94409
95062
  slug: doc2.slug,
94410
95063
  updated_at: doc2.updated_at,
94411
95064
  revision: doc2.revision,
94412
95065
  body_hash: stableHash(parsed.content),
94413
- frontmatter_hash: stableHash(userFm)
95066
+ frontmatter_hash: stableHash(userFm),
95067
+ inode
94414
95068
  });
94415
95069
  return nextState;
94416
95070
  }
@@ -94608,10 +95262,10 @@ class ReaddirpStream extends Readable {
94608
95262
  }
94609
95263
  async _formatEntry(dirent, path) {
94610
95264
  let entry2;
94611
- const basename = this._isDirent ? dirent.name : dirent;
95265
+ const basename2 = this._isDirent ? dirent.name : dirent;
94612
95266
  try {
94613
- const fullPath = presolve(pjoin(path, basename));
94614
- entry2 = { path: prelative(this._root, fullPath), fullPath, basename };
95267
+ const fullPath = presolve(pjoin(path, basename2));
95268
+ entry2 = { path: prelative(this._root, fullPath), fullPath, basename: basename2 };
94615
95269
  entry2[this._statsProp] = this._isDirent ? dirent : await this._stat(fullPath);
94616
95270
  } catch (err) {
94617
95271
  this._onError(err);
@@ -95138,9 +95792,9 @@ class NodeFsHandler {
95138
95792
  _watchWithNodeFs(path, listener) {
95139
95793
  const opts = this.fsw.options;
95140
95794
  const directory = sp.dirname(path);
95141
- const basename2 = sp.basename(path);
95795
+ const basename3 = sp.basename(path);
95142
95796
  const parent2 = this.fsw._getWatchedDir(directory);
95143
- parent2.add(basename2);
95797
+ parent2.add(basename3);
95144
95798
  const absolutePath = sp.resolve(path);
95145
95799
  const options3 = {
95146
95800
  persistent: opts.persistent
@@ -95150,7 +95804,7 @@ class NodeFsHandler {
95150
95804
  let closer;
95151
95805
  if (opts.usePolling) {
95152
95806
  const enableBin = opts.interval !== opts.binaryInterval;
95153
- options3.interval = enableBin && isBinaryPath(basename2) ? opts.binaryInterval : opts.interval;
95807
+ options3.interval = enableBin && isBinaryPath(basename3) ? opts.binaryInterval : opts.interval;
95154
95808
  closer = setFsWatchFileListener(path, absolutePath, options3, {
95155
95809
  listener,
95156
95810
  rawEmitter: this.fsw._emitRaw
@@ -95169,10 +95823,10 @@ class NodeFsHandler {
95169
95823
  return;
95170
95824
  }
95171
95825
  const dirname6 = sp.dirname(file2);
95172
- const basename2 = sp.basename(file2);
95826
+ const basename3 = sp.basename(file2);
95173
95827
  const parent2 = this.fsw._getWatchedDir(dirname6);
95174
95828
  let prevStats = stats;
95175
- if (parent2.has(basename2))
95829
+ if (parent2.has(basename3))
95176
95830
  return;
95177
95831
  const listener = async (path, newStats) => {
95178
95832
  if (!this.fsw._throttle(THROTTLE_MODE_WATCH, file2, 5))
@@ -95197,9 +95851,9 @@ class NodeFsHandler {
95197
95851
  prevStats = newStats2;
95198
95852
  }
95199
95853
  } catch (error53) {
95200
- this.fsw._remove(dirname6, basename2);
95854
+ this.fsw._remove(dirname6, basename3);
95201
95855
  }
95202
- } else if (parent2.has(basename2)) {
95856
+ } else if (parent2.has(basename3)) {
95203
95857
  const at = newStats.atimeMs;
95204
95858
  const mt = newStats.mtimeMs;
95205
95859
  if (!at || at <= mt || mt !== prevStats.mtimeMs) {
@@ -96053,7 +96707,121 @@ function watch(paths, options3 = {}) {
96053
96707
  var chokidar_default = { watch, FSWatcher };
96054
96708
 
96055
96709
  // src/lib/watcher.ts
96710
+ import { statSync as statSync3 } from "node:fs";
96056
96711
  import { sep as sep3 } from "node:path";
96712
+
96713
+ // src/lib/rename-classifier.ts
96714
+ import { basename as basename4, dirname as dirname7 } from "node:path";
96715
+
96716
+ class RenameClassifier {
96717
+ buffer = new Map;
96718
+ emit;
96719
+ opts;
96720
+ constructor(emit, opts) {
96721
+ this.emit = emit;
96722
+ this.opts = opts;
96723
+ }
96724
+ onAdd(absolutePath) {
96725
+ this.handleArrival(absolutePath);
96726
+ }
96727
+ onChange(absolutePath) {
96728
+ this.handleArrival(absolutePath);
96729
+ }
96730
+ handleArrival(absolutePath) {
96731
+ let inode;
96732
+ try {
96733
+ inode = this.opts.statSync(absolutePath).ino;
96734
+ } catch {
96735
+ this.emit({ kind: "upsert", absolutePath });
96736
+ return;
96737
+ }
96738
+ const buffered2 = this.buffer.get(inode);
96739
+ if (buffered2) {
96740
+ clearTimeout(buffered2.timer);
96741
+ this.buffer.delete(inode);
96742
+ this.emit({
96743
+ kind: "renamed",
96744
+ fromPath: buffered2.absolutePath,
96745
+ toPath: absolutePath,
96746
+ id: buffered2.stateEntry.id,
96747
+ expectedUpdatedAt: buffered2.stateEntry.updated_at,
96748
+ fromSlug: buffered2.stateEntry.slug
96749
+ });
96750
+ return;
96751
+ }
96752
+ this.emit({ kind: "upsert", absolutePath });
96753
+ }
96754
+ onUnlink(absolutePath) {
96755
+ const slug = slugFromPath(absolutePath);
96756
+ if (slug === null) {
96757
+ this.emit({
96758
+ kind: "unlink",
96759
+ absolutePath,
96760
+ stateEntry: null,
96761
+ renameFallback: false
96762
+ });
96763
+ return;
96764
+ }
96765
+ const entry2 = this.opts.getStateEntry(slug);
96766
+ if (!entry2 || entry2.inode === undefined) {
96767
+ this.emit({
96768
+ kind: "unlink",
96769
+ absolutePath,
96770
+ stateEntry: entry2,
96771
+ renameFallback: true
96772
+ });
96773
+ return;
96774
+ }
96775
+ const inode = entry2.inode;
96776
+ const existing = this.buffer.get(inode);
96777
+ if (existing)
96778
+ clearTimeout(existing.timer);
96779
+ const timer = setTimeout(() => {
96780
+ this.buffer.delete(inode);
96781
+ this.opts.onWindowExpired?.(inode);
96782
+ this.emit({
96783
+ kind: "unlink",
96784
+ absolutePath,
96785
+ stateEntry: entry2,
96786
+ renameFallback: false
96787
+ });
96788
+ }, this.opts.pairingWindowMs);
96789
+ this.buffer.set(inode, {
96790
+ absolutePath,
96791
+ inode,
96792
+ stateEntry: entry2,
96793
+ timer
96794
+ });
96795
+ }
96796
+ shutdown() {
96797
+ const entries2 = Array.from(this.buffer.entries());
96798
+ this.buffer.clear();
96799
+ for (const [, buffered2] of entries2) {
96800
+ clearTimeout(buffered2.timer);
96801
+ this.emit({
96802
+ kind: "unlink",
96803
+ absolutePath: buffered2.absolutePath,
96804
+ stateEntry: buffered2.stateEntry,
96805
+ renameFallback: false
96806
+ });
96807
+ }
96808
+ }
96809
+ size() {
96810
+ return this.buffer.size;
96811
+ }
96812
+ }
96813
+ function slugFromPath(absolutePath) {
96814
+ const base = basename4(absolutePath);
96815
+ if (!base.endsWith(".md"))
96816
+ return null;
96817
+ const slug = base.slice(0, -3);
96818
+ if (slug.length === 0)
96819
+ return null;
96820
+ dirname7(absolutePath);
96821
+ return slug;
96822
+ }
96823
+
96824
+ // src/lib/watcher.ts
96057
96825
  var DEFAULT_DEBOUNCE_MS = 500;
96058
96826
  var DEFAULT_MAX_PENDING_PATHS = 5000;
96059
96827
  function defaultIgnores(workspaceRoot) {
@@ -96082,15 +96850,16 @@ class DebounceMap {
96082
96850
  this.maxPending = opts.maxPending;
96083
96851
  }
96084
96852
  schedule(event) {
96085
- const existing = this.pending.get(event.absolutePath);
96853
+ const key = debounceKey(event);
96854
+ const existing = this.pending.get(key);
96086
96855
  if (existing) {
96087
96856
  clearTimeout(existing.timer);
96088
96857
  }
96089
96858
  const timer = setTimeout(() => {
96090
- this.pending.delete(event.absolutePath);
96859
+ this.pending.delete(key);
96091
96860
  this.handler(event);
96092
96861
  }, this.debounceMs);
96093
- this.pending.set(event.absolutePath, {
96862
+ this.pending.set(key, {
96094
96863
  timer,
96095
96864
  event,
96096
96865
  insertedAt: Date.now()
@@ -96132,12 +96901,26 @@ class DebounceMap {
96132
96901
  return this.pending.size;
96133
96902
  }
96134
96903
  }
96904
+ function debounceKey(event) {
96905
+ switch (event.kind) {
96906
+ case "upsert":
96907
+ case "unlink":
96908
+ return event.absolutePath;
96909
+ case "renamed":
96910
+ return event.toPath;
96911
+ }
96912
+ }
96135
96913
  function startWatcher(opts, handler) {
96136
96914
  const workspaceRoot = workspaceMirrorPath(opts.workspaceSlug);
96137
96915
  const debounce = new DebounceMap(handler, {
96138
96916
  debounceMs: opts.debounceMs ?? DEFAULT_DEBOUNCE_MS,
96139
96917
  maxPending: opts.maxPendingPaths ?? DEFAULT_MAX_PENDING_PATHS
96140
96918
  });
96919
+ const classifier = new RenameClassifier((event) => debounce.schedule(event), {
96920
+ pairingWindowMs: opts.renamePairingWindowMs ?? DEFAULT_DEBOUNCE_MS,
96921
+ getStateEntry: opts.getStateEntry,
96922
+ statSync: opts.statSync ?? ((p) => ({ ino: statSync3(p).ino }))
96923
+ });
96141
96924
  const factory = opts.chokidarFactory ?? ((paths, watcherOpts) => chokidar_default.watch(paths, watcherOpts));
96142
96925
  const watcher = factory(workspaceRoot, {
96143
96926
  ignored: defaultIgnores(workspaceRoot),
@@ -96149,11 +96932,12 @@ function startWatcher(opts, handler) {
96149
96932
  pollInterval: 50
96150
96933
  }
96151
96934
  });
96152
- watcher.on("add", (p) => debounce.schedule({ kind: "upsert", absolutePath: p }));
96153
- watcher.on("change", (p) => debounce.schedule({ kind: "upsert", absolutePath: p }));
96154
- watcher.on("unlink", (p) => debounce.schedule({ kind: "unlink", absolutePath: p }));
96935
+ watcher.on("add", (p) => classifier.onAdd(p));
96936
+ watcher.on("change", (p) => classifier.onChange(p));
96937
+ watcher.on("unlink", (p) => classifier.onUnlink(p));
96155
96938
  return {
96156
96939
  async stop() {
96940
+ classifier.shutdown();
96157
96941
  await watcher.close();
96158
96942
  },
96159
96943
  drainPending() {
@@ -96167,6 +96951,90 @@ function startWatcher(opts, handler) {
96167
96951
 
96168
96952
  // src/commands/start.ts
96169
96953
  var SHUTDOWN_DRAIN_TIMEOUT_MS = 5000;
96954
+ function backfillStateInodes(workspaceSlug, state2, statFn = (p) => statSync4(p)) {
96955
+ let changed = false;
96956
+ const nextDocs = {};
96957
+ for (const [id2, entry2] of Object.entries(state2.documents)) {
96958
+ if (entry2.inode !== undefined) {
96959
+ nextDocs[id2] = entry2;
96960
+ continue;
96961
+ }
96962
+ try {
96963
+ const path = entityFilePath(workspaceSlug, "documents", entry2.slug);
96964
+ const inode = statFn(path).ino;
96965
+ nextDocs[id2] = { ...entry2, inode };
96966
+ changed = true;
96967
+ } catch {
96968
+ nextDocs[id2] = entry2;
96969
+ }
96970
+ }
96971
+ if (!changed)
96972
+ return { state: state2, changed: false };
96973
+ return { state: { ...state2, documents: nextDocs }, changed: true };
96974
+ }
96975
+ function makeWatchHandler(deps) {
96976
+ const {
96977
+ workspaceSlug,
96978
+ baseUrl,
96979
+ pat,
96980
+ spinner,
96981
+ mutex,
96982
+ getState,
96983
+ setState,
96984
+ fetchImpl,
96985
+ now
96986
+ } = deps;
96987
+ return async (event) => {
96988
+ try {
96989
+ if (event.kind === "renamed") {
96990
+ const result3 = await pushRename({
96991
+ workspaceSlug,
96992
+ baseUrl,
96993
+ pat,
96994
+ fromPath: event.fromPath,
96995
+ toPath: event.toPath,
96996
+ id: event.id,
96997
+ expectedUpdatedAt: event.expectedUpdatedAt,
96998
+ fromSlug: event.fromSlug,
96999
+ state: getState(),
97000
+ deps: { fetchImpl, now, mutex }
97001
+ });
97002
+ setState(result3.state);
97003
+ reportOutcome(spinner, workspaceSlug, result3.state, result3.outcome);
97004
+ return;
97005
+ }
97006
+ const absolutePath = event.kind === "upsert" ? event.absolutePath : event.absolutePath;
97007
+ const classified = classifyMirrorPath({ workspaceSlug, absolutePath });
97008
+ if (!classified)
97009
+ return;
97010
+ if (event.kind === "unlink" && event.renameFallback) {
97011
+ const slugHint = event.stateEntry?.slug ? `documents/${event.stateEntry.slug}` : absolutePath;
97012
+ spinner.warn(`Rename pair miss for ${slugHint} — no inode in state, treated as delete (classifier degraded to create+delete). ` + `If you just ran \`mv\`, the cloud row was likely tombstoned; check ~/.plinth/_conflicts/ or restore from cloud.`);
97013
+ }
97014
+ const result2 = await pushFile({
97015
+ workspaceSlug,
97016
+ baseUrl,
97017
+ pat,
97018
+ absolutePath,
97019
+ entityType: classified.entityType,
97020
+ state: getState(),
97021
+ exists: event.kind === "upsert",
97022
+ unlinkContext: event.kind === "unlink" && event.stateEntry ? {
97023
+ id: event.stateEntry.id,
97024
+ slug: event.stateEntry.slug,
97025
+ updated_at: event.stateEntry.updated_at
97026
+ } : null,
97027
+ deps: { fetchImpl, now, mutex }
97028
+ });
97029
+ setState(result2.state);
97030
+ reportOutcome(spinner, workspaceSlug, result2.state, result2.outcome);
97031
+ } catch (err) {
97032
+ const msg = err instanceof Error ? err.message : "unknown push error";
97033
+ const path = event.kind === "renamed" ? event.toPath : event.absolutePath;
97034
+ spinner.warn(`Push failed for ${path}: ${msg}`);
97035
+ }
97036
+ };
97037
+ }
96170
97038
  async function runStart(workspaceSlug, deps = {}) {
96171
97039
  const out = new Output({ sink: deps.sink, noColor: deps.noColor });
96172
97040
  const keychain = deps.keychain ?? macKeychain;
@@ -96196,40 +97064,44 @@ async function runStart(workspaceSlug, deps = {}) {
96196
97064
  out.error(`No personal access token in Keychain for "${slug}".`);
96197
97065
  return { exitCode: 1 };
96198
97066
  }
96199
- const warnedPaths = new Set;
96200
97067
  let state2 = loadState(slug);
97068
+ const backfill = backfillStateInodes(slug, state2);
97069
+ if (backfill.changed) {
97070
+ state2 = backfill.state;
97071
+ writeState(state2);
97072
+ }
96201
97073
  const spinner = (deps.spinnerFactory ?? (() => createSpinner({ noColor: deps.noColor })))();
96202
97074
  spinner.start(`Watching ${slug} · ${Object.keys(state2.documents).length} docs tracked`);
96203
- const handler = async (event) => {
96204
- const classified = classifyMirrorPath({
96205
- workspaceSlug: slug,
96206
- absolutePath: event.absolutePath
96207
- });
96208
- if (!classified)
96209
- return;
96210
- try {
96211
- const result2 = await pushFile({
96212
- workspaceSlug: slug,
96213
- baseUrl: entry2.api_base_url,
96214
- pat,
96215
- absolutePath: event.absolutePath,
96216
- entityType: classified.entityType,
96217
- state: state2,
96218
- exists: event.kind === "upsert",
96219
- deps: { fetchImpl, now: deps.now }
96220
- });
96221
- state2 = result2.state;
96222
- reportOutcome(spinner, slug, state2, result2.outcome, warnedPaths);
96223
- } catch (err) {
96224
- const msg = err instanceof Error ? err.message : "unknown push error";
96225
- spinner.warn(`Push failed for ${event.absolutePath}: ${msg}`);
96226
- }
97075
+ const mutex = new PerIdMutex;
97076
+ const getStateEntry = (entitySlug) => {
97077
+ const entry3 = getEntryBySlug(state2, entitySlug);
97078
+ if (!entry3)
97079
+ return null;
97080
+ return {
97081
+ id: entry3.id,
97082
+ slug: entry3.slug,
97083
+ updated_at: entry3.updated_at,
97084
+ inode: entry3.inode
97085
+ };
96227
97086
  };
97087
+ const handler = makeWatchHandler({
97088
+ workspaceSlug: slug,
97089
+ baseUrl: entry2.api_base_url,
97090
+ pat,
97091
+ spinner,
97092
+ mutex,
97093
+ getState: () => state2,
97094
+ setState: (s2) => {
97095
+ state2 = s2;
97096
+ },
97097
+ fetchImpl,
97098
+ now: deps.now
97099
+ });
96228
97100
  if (deps.skipWatch) {
96229
97101
  spinner.stop();
96230
97102
  return { exitCode: 0 };
96231
97103
  }
96232
- const watcher = startWatcher({ workspaceSlug: slug }, handler);
97104
+ const watcher = startWatcher({ workspaceSlug: slug, getStateEntry }, handler);
96233
97105
  const shutdown = (signal2) => {
96234
97106
  (async () => {
96235
97107
  spinner.warn(`Received ${signal2}, draining pending pushes…`);
@@ -96252,35 +97124,41 @@ async function runStart(workspaceSlug, deps = {}) {
96252
97124
  });
96253
97125
  return { exitCode: 0 };
96254
97126
  }
96255
- function reportOutcome(spinner, slug, state2, outcome, warnedPaths) {
97127
+ function reportOutcome(spinner, slug, state2, outcome) {
96256
97128
  switch (outcome.kind) {
96257
97129
  case "pushed":
96258
97130
  spinner.success(`Pushed documents/${outcome.entitySlug}`);
96259
97131
  spinner.update(`Watching ${slug} · ${Object.keys(state2.documents).length} docs tracked`);
96260
97132
  return;
97133
+ case "created":
97134
+ spinner.success(`Created documents/${outcome.entitySlug}`);
97135
+ spinner.update(`Watching ${slug} · ${Object.keys(state2.documents).length} docs tracked`);
97136
+ return;
97137
+ case "deleted":
97138
+ spinner.success(`Deleted documents/${outcome.entitySlug}`);
97139
+ spinner.update(`Watching ${slug} · ${Object.keys(state2.documents).length} docs tracked`);
97140
+ return;
97141
+ case "renamed":
97142
+ spinner.success(`Renamed documents/${outcome.fromSlug} → documents/${outcome.toSlug}`);
97143
+ spinner.update(`Watching ${slug} · ${Object.keys(state2.documents).length} docs tracked`);
97144
+ return;
96261
97145
  case "noop":
96262
97146
  return;
96263
97147
  case "conflict":
96264
97148
  spinner.warn(`Conflict on documents/${outcome.entitySlug} → ${outcome.stashPath} (cloud-wins; local copy stashed)`);
96265
97149
  spinner.update(`Watching ${slug} · ${Object.keys(state2.documents).length} docs tracked`);
96266
97150
  return;
96267
- case "not_found":
96268
- spinner.warn(`documents/${outcome.entitySlug} not on cloud (deleted or never existed)`);
97151
+ case "create_slug_conflict":
97152
+ spinner.warn(`Slug conflict on documents/${outcome.entitySlug} → ${outcome.stashPath} (cloud copy adopted; local stashed)`);
97153
+ spinner.update(`Watching ${slug} · ${Object.keys(state2.documents).length} docs tracked`);
96269
97154
  return;
96270
- case "deferred_new_file": {
96271
- if (warnedPaths.has(outcome.absolutePath))
96272
- return;
96273
- warnedPaths.add(outcome.absolutePath);
96274
- spinner.warn(`${outcome.absolutePath} - new local file not pushed (rename + create handling lands in PR #8)`);
97155
+ case "rename_slug_conflict":
97156
+ spinner.warn(`Rename slug conflict documents/${outcome.fromSlug} → documents/${outcome.toSlug}: target slug already exists on cloud. Local stashed at ${outcome.stashPath}`);
97157
+ spinner.update(`Watching ${slug} · ${Object.keys(state2.documents).length} docs tracked`);
96275
97158
  return;
96276
- }
96277
- case "deferred_delete": {
96278
- if (warnedPaths.has(outcome.absolutePath))
96279
- return;
96280
- warnedPaths.add(outcome.absolutePath);
96281
- spinner.warn(`${outcome.absolutePath} - local delete not pushed (delete handling lands in PR #8)`);
97159
+ case "not_found":
97160
+ spinner.warn(`documents/${outcome.entitySlug} not on cloud (deleted or never existed)`);
96282
97161
  return;
96283
- }
96284
97162
  case "ignored":
96285
97163
  return;
96286
97164
  }