emdash-emit 0.1.17 → 0.1.18

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 (177) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/cli/commands/pull.d.ts +2 -10
  3. package/dist/cli/commands/pull.d.ts.map +1 -1
  4. package/dist/cli/commands/pull.js +12 -30
  5. package/dist/cli/commands/pull.js.map +1 -1
  6. package/dist/client/auth.d.ts.map +1 -1
  7. package/dist/client/auth.js +2 -1
  8. package/dist/client/auth.js.map +1 -1
  9. package/dist/client/endpoints.d.ts +19 -0
  10. package/dist/client/endpoints.d.ts.map +1 -0
  11. package/dist/client/endpoints.js +69 -0
  12. package/dist/client/endpoints.js.map +1 -0
  13. package/dist/client/http.d.ts +4 -2
  14. package/dist/client/http.d.ts.map +1 -1
  15. package/dist/client/http.js.map +1 -1
  16. package/dist/client/lock.d.ts.map +1 -1
  17. package/dist/client/lock.js +4 -3
  18. package/dist/client/lock.js.map +1 -1
  19. package/dist/client/mapper.d.ts.map +1 -1
  20. package/dist/client/mapper.js +5 -3
  21. package/dist/client/mapper.js.map +1 -1
  22. package/dist/client/media-id.d.ts +2 -0
  23. package/dist/client/media-id.d.ts.map +1 -0
  24. package/dist/client/media-id.js +23 -0
  25. package/dist/client/media-id.js.map +1 -0
  26. package/dist/client/media.d.ts +8 -7
  27. package/dist/client/media.d.ts.map +1 -1
  28. package/dist/client/media.js +58 -17
  29. package/dist/client/media.js.map +1 -1
  30. package/dist/db/baseline.d.ts +37 -0
  31. package/dist/db/baseline.d.ts.map +1 -0
  32. package/dist/db/baseline.js +74 -0
  33. package/dist/db/baseline.js.map +1 -0
  34. package/dist/db/cache.d.ts +3 -2
  35. package/dist/db/cache.d.ts.map +1 -1
  36. package/dist/db/cache.js +3 -2
  37. package/dist/db/cache.js.map +1 -1
  38. package/dist/index.d.ts +4 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/mcp/tools/sync-pull.d.ts.map +1 -1
  43. package/dist/mcp/tools/sync-pull.js +15 -2
  44. package/dist/mcp/tools/sync-pull.js.map +1 -1
  45. package/dist/presentation/article_report.d.ts +40 -0
  46. package/dist/presentation/article_report.d.ts.map +1 -0
  47. package/dist/presentation/article_report.js +80 -0
  48. package/dist/presentation/article_report.js.map +1 -0
  49. package/dist/sync/bylines.d.ts.map +1 -1
  50. package/dist/sync/bylines.js +2 -1
  51. package/dist/sync/bylines.js.map +1 -1
  52. package/dist/sync/conflicts.js +1 -1
  53. package/dist/sync/conflicts.js.map +1 -1
  54. package/dist/sync/delete.d.ts +0 -5
  55. package/dist/sync/delete.d.ts.map +1 -1
  56. package/dist/sync/delete.js +5 -11
  57. package/dist/sync/delete.js.map +1 -1
  58. package/dist/sync/diff.d.ts.map +1 -1
  59. package/dist/sync/diff.js +4 -5
  60. package/dist/sync/diff.js.map +1 -1
  61. package/dist/sync/doctor.d.ts.map +1 -1
  62. package/dist/sync/doctor.js +11 -14
  63. package/dist/sync/doctor.js.map +1 -1
  64. package/dist/sync/guards.d.ts +5 -0
  65. package/dist/sync/guards.d.ts.map +1 -1
  66. package/dist/sync/guards.js +61 -0
  67. package/dist/sync/guards.js.map +1 -1
  68. package/dist/sync/media/body_scan.d.ts.map +1 -1
  69. package/dist/sync/media/body_scan.js +9 -3
  70. package/dist/sync/media/body_scan.js.map +1 -1
  71. package/dist/sync/media/companions.d.ts +2 -9
  72. package/dist/sync/media/companions.d.ts.map +1 -1
  73. package/dist/sync/media/companions.js +5 -23
  74. package/dist/sync/media/companions.js.map +1 -1
  75. package/dist/sync/media/mirror_item.d.ts.map +1 -1
  76. package/dist/sync/media/mirror_item.js +33 -21
  77. package/dist/sync/media/mirror_item.js.map +1 -1
  78. package/dist/sync/media/pull_article.d.ts.map +1 -1
  79. package/dist/sync/media/pull_article.js +3 -2
  80. package/dist/sync/media/pull_article.js.map +1 -1
  81. package/dist/sync/media/pull_library.d.ts.map +1 -1
  82. package/dist/sync/media/pull_library.js +16 -11
  83. package/dist/sync/media/pull_library.js.map +1 -1
  84. package/dist/sync/media/remote_index.d.ts.map +1 -1
  85. package/dist/sync/media/remote_index.js +2 -1
  86. package/dist/sync/media/remote_index.js.map +1 -1
  87. package/dist/sync/media/types.d.ts +4 -3
  88. package/dist/sync/media/types.d.ts.map +1 -1
  89. package/dist/sync/media/types.js.map +1 -1
  90. package/dist/sync/preflight.d.ts +3 -2
  91. package/dist/sync/preflight.d.ts.map +1 -1
  92. package/dist/sync/preflight.js +19 -10
  93. package/dist/sync/preflight.js.map +1 -1
  94. package/dist/sync/pull.d.ts +6 -44
  95. package/dist/sync/pull.d.ts.map +1 -1
  96. package/dist/sync/pull.js +37 -145
  97. package/dist/sync/pull.js.map +1 -1
  98. package/dist/sync/push/commit.d.ts +60 -0
  99. package/dist/sync/push/commit.d.ts.map +1 -0
  100. package/dist/sync/push/commit.js +230 -0
  101. package/dist/sync/push/commit.js.map +1 -0
  102. package/dist/sync/push/guards.d.ts +33 -0
  103. package/dist/sync/push/guards.d.ts.map +1 -0
  104. package/dist/sync/push/guards.js +110 -0
  105. package/dist/sync/push/guards.js.map +1 -0
  106. package/dist/sync/push/index.d.ts +9 -0
  107. package/dist/sync/push/index.d.ts.map +1 -0
  108. package/dist/sync/push/index.js +350 -0
  109. package/dist/sync/push/index.js.map +1 -0
  110. package/dist/sync/push/media_stage.d.ts +41 -0
  111. package/dist/sync/push/media_stage.d.ts.map +1 -0
  112. package/dist/sync/push/media_stage.js +309 -0
  113. package/dist/sync/push/media_stage.js.map +1 -0
  114. package/dist/sync/push/merge_stage.d.ts +37 -0
  115. package/dist/sync/push/merge_stage.d.ts.map +1 -0
  116. package/dist/sync/push/merge_stage.js +243 -0
  117. package/dist/sync/push/merge_stage.js.map +1 -0
  118. package/dist/sync/push/preflight_stage.d.ts +6 -0
  119. package/dist/sync/push/preflight_stage.d.ts.map +1 -0
  120. package/dist/sync/push/preflight_stage.js +40 -0
  121. package/dist/sync/push/preflight_stage.js.map +1 -0
  122. package/dist/sync/push/tax_audit.d.ts +24 -0
  123. package/dist/sync/push/tax_audit.d.ts.map +1 -0
  124. package/dist/sync/push/tax_audit.js +70 -0
  125. package/dist/sync/push/tax_audit.js.map +1 -0
  126. package/dist/sync/push/taxonomy_plan.d.ts +36 -0
  127. package/dist/sync/push/taxonomy_plan.d.ts.map +1 -0
  128. package/dist/sync/push/taxonomy_plan.js +136 -0
  129. package/dist/sync/push/taxonomy_plan.js.map +1 -0
  130. package/dist/sync/push/transport.d.ts +10 -0
  131. package/dist/sync/push/transport.d.ts.map +1 -0
  132. package/dist/sync/push/transport.js +74 -0
  133. package/dist/sync/push/transport.js.map +1 -0
  134. package/dist/sync/push/types.d.ts +62 -0
  135. package/dist/sync/push/types.d.ts.map +1 -0
  136. package/dist/sync/push/types.js +23 -0
  137. package/dist/sync/push/types.js.map +1 -0
  138. package/dist/sync/push.d.ts +2 -57
  139. package/dist/sync/push.d.ts.map +1 -1
  140. package/dist/sync/push.js +2 -1184
  141. package/dist/sync/push.js.map +1 -1
  142. package/dist/sync/status.d.ts.map +1 -1
  143. package/dist/sync/status.js +5 -4
  144. package/dist/sync/status.js.map +1 -1
  145. package/dist/sync/taxonomies.d.ts.map +1 -1
  146. package/dist/sync/taxonomies.js +2 -1
  147. package/dist/sync/taxonomies.js.map +1 -1
  148. package/dist/sync/terms.d.ts.map +1 -1
  149. package/dist/sync/terms.js +2 -1
  150. package/dist/sync/terms.js.map +1 -1
  151. package/dist/sync/workspace/index.d.ts +41 -0
  152. package/dist/sync/workspace/index.d.ts.map +1 -0
  153. package/dist/sync/workspace/index.js +124 -0
  154. package/dist/sync/workspace/index.js.map +1 -0
  155. package/dist/utils/frontmatter.d.ts.map +1 -1
  156. package/dist/utils/frontmatter.js +1 -3
  157. package/dist/utils/frontmatter.js.map +1 -1
  158. package/dist/utils/media-body-refs.d.ts +24 -0
  159. package/dist/utils/media-body-refs.d.ts.map +1 -1
  160. package/dist/utils/media-body-refs.js +51 -0
  161. package/dist/utils/media-body-refs.js.map +1 -1
  162. package/dist/utils/media-companion.d.ts +13 -0
  163. package/dist/utils/media-companion.d.ts.map +1 -0
  164. package/dist/utils/media-companion.js +42 -0
  165. package/dist/utils/media-companion.js.map +1 -0
  166. package/dist/utils/media-id.d.ts +46 -0
  167. package/dist/utils/media-id.d.ts.map +1 -0
  168. package/dist/utils/media-id.js +84 -0
  169. package/dist/utils/media-id.js.map +1 -0
  170. package/dist/utils/naming.d.ts.map +1 -1
  171. package/dist/utils/naming.js +3 -2
  172. package/dist/utils/naming.js.map +1 -1
  173. package/dist/utils/ulid.d.ts +31 -0
  174. package/dist/utils/ulid.d.ts.map +1 -0
  175. package/dist/utils/ulid.js +65 -0
  176. package/dist/utils/ulid.js.map +1 -0
  177. package/package.json +1 -1
package/dist/sync/push.js CHANGED
@@ -19,1190 +19,8 @@
19
19
  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
20
  * SOFTWARE.
21
21
  */
22
- import fs from 'node:fs';
23
- import path from 'node:path';
24
- import yaml from 'js-yaml';
25
- import { withEntryLock } from '../client/lock.js';
26
- import { apiResponseToLocal, extractMediaUlid, localToApiPayload, } from '../client/mapper.js';
27
- import { ApiError } from '../client/error.js';
28
- import { createAuthedFetch } from '../client/http.js';
29
- import { unwrapItem, extractRev } from '../client/api.js';
30
- import { classifyCodeFenceLines } from '../client/portable-text.js';
31
- import { computeArticleRelativePath, extractDatePrefixFromPath, parseMarkdown, serializeMarkdown, } from '../utils/frontmatter.js';
32
- import { loadLocalTaxonomyMaps, loadLocalTaxonomyCandidates } from './taxonomies.js';
33
- import { localizeArticleMediaRefs } from '../utils/media-body-refs.js';
34
- import { runPreflightChecks } from './preflight.js';
35
- import { calculateHash, carriesDepthFreeMediaRef, CONFLICTS_SUBDIR, isQuarantinedRelativePath, quarantineLocalCopy, } from './pull.js';
36
- import { hybrid3WayMerge } from './merge.js';
37
- import { syncRemoteBylines } from './bylines.js';
38
- import { updateConfigSyncedVersion } from '../utils/config.js';
39
- import { fetchAssignedTaxonomies, computeTaxonomyHash } from './terms.js';
40
- import { fetchRemoteMediaDetail, resolveImageMimeType, toRootRelativeMediaUrl, uploadMediaFile, } from '../client/media.js';
41
- import { promoteDraftMediaFile } from './media.js';
42
- export class PushValidationError extends Error {
43
- errors;
44
- constructor(message, errors) {
45
- super(message);
46
- this.name = 'PushValidationError';
47
- this.errors = errors;
48
- }
49
- }
50
22
  /**
51
- * Helper to transition content status via EmDash /publish or /unpublish endpoints.
23
+ * Re-exports pushArticle and push pipeline stages including [LOCAL_IMAGE_AUTO_UPLOAD] guards.
52
24
  */
53
- async function transitionContentStatus(collection, id, targetStatus, currentRev, publishedAt, options, fetchFn) {
54
- const isPublishing = targetStatus === 'published';
55
- const endpoint = new URL(`/_emdash/api/content/${collection}/${id}/${isPublishing ? 'publish' : 'unpublish'}`, options.origin).toString();
56
- const body = { _rev: currentRev };
57
- if (isPublishing && publishedAt) {
58
- body.publishedAt = publishedAt;
59
- }
60
- const res = await fetchFn(endpoint, {
61
- method: 'POST',
62
- headers: {
63
- Authorization: `Bearer ${options.token}`,
64
- 'Content-Type': 'application/json',
65
- },
66
- body: JSON.stringify(body),
67
- });
68
- if (!res.ok) {
69
- const errText = await res.text().catch(() => '');
70
- throw new ApiError({
71
- message: `Failed to ${isPublishing ? 'publish' : 'unpublish'} content: ${res.statusText}${errText ? ` - ${errText}` : ''}`,
72
- code: isPublishing ? 'PUBLISH_FAILED' : 'UNPUBLISH_FAILED',
73
- status: res.status,
74
- endpoint,
75
- suggestion: isPublishing
76
- ? 'Verify publish permissions and revision state.'
77
- : 'Verify unpublish permissions and revision state.',
78
- });
79
- }
80
- const json = (await res.json().catch(() => ({})));
81
- return extractRev(json, currentRev);
82
- }
83
- /**
84
- * Determines the featured_image value to send to EmDash, returning any warnings.
85
- *
86
- * Contract (confirmed against production EmDash): `featured_image: { id: <ULID> }` is
87
- * accepted on its own and resolved server-side - the server stores the media id and
88
- * normalizes `provider` itself for media held in the media library. Clients must never
89
- * fabricate provider, src, storageKey or dimensions.
90
- *
91
- * - undefined : leave the payload untouched (field omitted, remote value preserved).
92
- * - null : send an explicit null to clear the hero image.
93
- * - otherwise : if the cached remote entry references the same media ULID, resend the remote
94
- * object verbatim so its original shape (provider "external" with src, or
95
- * provider "local" with meta.storageKey, blurhash and dimensions) survives the
96
- * round-trip byte-for-byte rather than being re-resolved from a bare id.
97
- * Otherwise send the canonical minimal { id: <ULID> }.
98
- *
99
- * Local media/*.yaml is never a payload source: it lacks meta.storageKey, and re-sending
100
- * possibly stale companion metadata would overwrite authoritative remote state. The companion
101
- * file is used only to cross-check that a referenced ULID is one this workspace knows about.
102
- */
103
- function preserveOrResolveFeaturedImage(payload, featuredImage, cache, id, workspaceRoot) {
104
- const warnings = [];
105
- if (featuredImage === undefined)
106
- return warnings;
107
- if (featuredImage === null) {
108
- payload.data.featured_image = null;
109
- return warnings;
110
- }
111
- // Canonicalize once: uppercase Crockford base32. extractMediaUlid() also folds a media
112
- // URL-shaped value back to its ULID so cached-remote comparison still succeeds.
113
- const targetUlid = (extractMediaUlid(featuredImage) ?? featuredImage.trim()).toUpperCase();
114
- // 1. Resend the exact remote object when it already points at this media ULID.
115
- if (cache) {
116
- try {
117
- const cachedEntry = cache.getEntry(id);
118
- if (cachedEntry) {
119
- const raw = JSON.parse(cachedEntry.rawJson);
120
- const item = (raw.item || raw);
121
- const itemData = (item.data || {});
122
- const remoteFi = itemData.featured_image;
123
- if (remoteFi && extractMediaUlid(remoteFi) === targetUlid) {
124
- payload.data.featured_image = remoteFi;
125
- return warnings;
126
- }
127
- }
128
- }
129
- catch (err) {
130
- // Non-fatal: cache inspection failure falls through to canonical minimal payload.
131
- warnings.push(`[push] Unable to inspect cache for featured_image restoration: ${err.message}`);
132
- }
133
- }
134
- // 2. Cross-check the reference against the workspace media mirror (verification only).
135
- // A missing entry here is NOT a payload problem - { id: <ULID> } is a valid request that
136
- // EmDash resolves. It only means Emit cannot confirm the media exists, which is exactly
137
- // where a hallucinated or mistyped ULID would surface. A workspace that does not mirror
138
- // media (no media/, or an empty one) is not warned about.
139
- if (workspaceRoot) {
140
- const mediaDir = path.join(workspaceRoot, 'media');
141
- const mirrored = fs.existsSync(mediaDir) ? fs.readdirSync(mediaDir) : [];
142
- if (mirrored.length > 0) {
143
- // Anchored on the id segment so a ULID appearing inside another media item's
144
- // filename can never be mistaken for its own metadata file.
145
- const companion = mirrored.find((f) => f.endsWith('.yaml') && (f.includes(`_${targetUlid}-`) || f.startsWith(`${targetUlid}-`)));
146
- if (companion) {
147
- try {
148
- const meta = yaml.load(fs.readFileSync(path.join(mediaDir, companion), 'utf8'));
149
- const metaId = typeof meta?.id === 'string' ? meta.id.trim().toUpperCase() : undefined;
150
- if (metaId && metaId !== targetUlid) {
151
- warnings.push(`Local media metadata '${companion}' describes a different media ID (${metaId}); it was ignored for featuredImage ${targetUlid}.`);
152
- }
153
- }
154
- catch (err) {
155
- // Non-fatal: metadata inspection is advisory only.
156
- warnings.push(`[push] Failed to read local media metadata '${companion}': ${err.message}`);
157
- }
158
- }
159
- else {
160
- warnings.push(`featuredImage ${targetUlid} was not found in the mirrored media under media/, so Emit could not confirm this media ID exists. The ID is sent as-is and EmDash resolves it, but if the ID is wrong the hero image will be missing. Upload the image first (EmDash Admin or media_upload) and use the ULID it returns, or run "emit pull-media" to mirror remote media locally.`);
161
- }
162
- }
163
- }
164
- // 3. Canonical minimal payload (identical to what localToApiPayload produces).
165
- payload.data.featured_image = { id: targetUlid };
166
- return warnings;
167
- }
168
- /**
169
- * Rewrites standalone local draft image lines in Markdown body to their confirmed
170
- * canonical root-relative URLs (`/_emdash/api/media/file/<storageKey>`), skipping
171
- * fenced code blocks and opaque block fences.
172
- */
173
- function rewriteDraftImagesInBody(body, draftImages, uploadedByAbsPath) {
174
- if (draftImages.length === 0)
175
- return body;
176
- const rawPathToResult = new Map();
177
- for (const ref of draftImages) {
178
- const res = uploadedByAbsPath.get(ref.resolvedAbsPath);
179
- if (res) {
180
- rawPathToResult.set(ref.rawPath, res);
181
- rawPathToResult.set(ref.rawPath.trim(), res);
182
- }
183
- }
184
- const lines = body.split('\n');
185
- // Same CommonMark-aware fence walker that G5 / G11 use, so a line can never be
186
- // rewritten here while being skipped there (or vice versa).
187
- const fenceStates = classifyCodeFenceLines(body);
188
- const standaloneRegex = /^!\[([^\]]*)\]\(([^)]+)\)$/;
189
- return lines
190
- .map((line, index) => {
191
- const state = fenceStates[index];
192
- if (state.isFenceDelimiter || state.insideCodeFence) {
193
- return line;
194
- }
195
- const hasCr = line.endsWith('\r');
196
- const cleanLine = hasCr ? line.slice(0, -1) : line;
197
- const trimmed = cleanLine.trim();
198
- // Opaque EmDash block fences are managed by G9 and must stay untouched
199
- if (/^<!--ec:block .+ -->$/.test(trimmed)) {
200
- return line;
201
- }
202
- const match = trimmed.match(standaloneRegex);
203
- if (!match)
204
- return line;
205
- const alt = match[1];
206
- const rawPath = match[2];
207
- const uploaded = rawPathToResult.get(rawPath) ?? rawPathToResult.get(rawPath.trim());
208
- if (!uploaded)
209
- return line;
210
- const replaced = `![${alt}](${uploaded.url})`;
211
- return hasCr ? `${replaced}\r` : replaced;
212
- })
213
- .join('\n');
214
- }
215
- /**
216
- * Promotes uploaded draft images from `media/drafts/<filename>` into
217
- * `media/<markdownRef>.<ext>` along with companion `.yaml` metadata, where markdownRef is derived from the
218
- * and commits `status = 'ready'` in `media_state`.
219
- *
220
- * Protected by try/catch per asset (C-2) so that any local filesystem failure
221
- * after a successful remote article PUT/POST is downgraded to a warning rather than
222
- * aborting `saveBaseSnapshot` / `saveSyncState`.
223
- */
224
- function finalizePromotedDraftImages(draftImages, uploadedByAbsPath, options, warnings) {
225
- if (draftImages.length === 0)
226
- return;
227
- const processedAbsPaths = new Set();
228
- const promotedRelPaths = [];
229
- const reusedRelPaths = [];
230
- const unverifiedAssets = [];
231
- for (const ref of draftImages) {
232
- if (processedAbsPaths.has(ref.resolvedAbsPath))
233
- continue;
234
- processedAbsPaths.add(ref.resolvedAbsPath);
235
- const uploaded = uploadedByAbsPath.get(ref.resolvedAbsPath);
236
- if (ref.alreadyPromoted) {
237
- reusedRelPaths.push(ref.alreadyPromoted.promotedRelPath);
238
- if (uploaded?.remoteVerification === 'unknown') {
239
- unverifiedAssets.push(`${ref.alreadyPromoted.promotedRelPath} (${ref.alreadyPromoted.mediaId})${uploaded.remoteVerificationNote ? `: ${uploaded.remoteVerificationNote}` : ''}`);
240
- }
241
- continue;
242
- }
243
- if (!uploaded)
244
- continue;
245
- if (uploaded.remoteVerification === 'unknown') {
246
- unverifiedAssets.push(`${uploaded.filename} (${uploaded.mediaId})${uploaded.remoteVerificationNote ? `: ${uploaded.remoteVerificationNote}` : ''}`);
247
- }
248
- try {
249
- const promoted = promoteDraftMediaFile({
250
- workspaceRoot: options.workspaceRoot,
251
- origin: options.origin,
252
- draftAbsPath: ref.resolvedAbsPath,
253
- item: {
254
- id: uploaded.mediaId,
255
- filename: uploaded.filename,
256
- mimeType: uploaded.mimeType,
257
- size: uploaded.size,
258
- width: uploaded.width ?? null,
259
- height: uploaded.height ?? null,
260
- alt: ref.alt || '',
261
- caption: '',
262
- storageKey: uploaded.storageKey,
263
- contentHash: uploaded.contentHash,
264
- status: 'ready',
265
- url: uploaded.url,
266
- createdAt: uploaded.createdAt || new Date().toISOString(),
267
- },
268
- removeDraft: true,
269
- });
270
- options.cache.saveMediaState({
271
- localHash: uploaded.localHash,
272
- mediaId: uploaded.mediaId,
273
- storageKey: uploaded.storageKey,
274
- url: uploaded.url,
275
- workspacePath: promoted.filePath,
276
- markdownRef: promoted.markdownRef,
277
- status: 'ready',
278
- });
279
- promotedRelPaths.push(promoted.filePath);
280
- }
281
- catch (err) {
282
- // Keep status='uploaded' in media_state so subsequent runs know the remote upload succeeded
283
- warnings.push(`[LOCAL_IMAGE_AUTO_UPLOAD] Uploaded '${ref.filename}' (${uploaded.mediaId}) to remote, but failed to promote local file to media/: ${err.message}`);
284
- }
285
- }
286
- if (promotedRelPaths.length > 0) {
287
- warnings.push(`[LOCAL_IMAGE_AUTO_UPLOAD] Auto-uploaded ${promotedRelPaths.length} draft image(s) and promoted to: ${promotedRelPaths.join(', ')}`);
288
- }
289
- if (reusedRelPaths.length > 0) {
290
- warnings.push(`[LOCAL_IMAGE_AUTO_UPLOAD] Reused ${reusedRelPaths.length} already-promoted media asset(s): ${reusedRelPaths.join(', ')}`);
291
- }
292
- if (unverifiedAssets.length > 0) {
293
- warnings.push(`[MEDIA_EXISTENCE_UNVERIFIED] The remote server did not confirm these reused media records (GET /_emdash/api/media/{id} unsupported or failed): ${unverifiedAssets.join(', ')}. The cached /_emdash/api/media/file/<storageKey> URL was reused as-is; run "emit pull-media" to re-verify or re-upload if the images 404.`);
294
- }
295
- }
296
- /**
297
- * Executes P2–P4 (upload/resolve draft images and rewrite Markdown body in-memory).
298
- * Already-promoted assets are verified against the remote before their canonical URL is
299
- * baked into the body, so a media record deleted on the EmDash side can never produce an
300
- * article that renders HTTP 404 images in production.
301
- */
302
- async function uploadAndRewriteDraftImages(body, draftImages, options, fetchFn) {
303
- const uploadedByAbsPath = new Map();
304
- if (draftImages.length === 0) {
305
- return { body, uploadedByAbsPath };
306
- }
307
- for (const draftRef of draftImages) {
308
- if (uploadedByAbsPath.has(draftRef.resolvedAbsPath)) {
309
- continue;
310
- }
311
- if (draftRef.alreadyPromoted) {
312
- const promoted = draftRef.alreadyPromoted;
313
- const remoteCheck = await fetchRemoteMediaDetail(promoted.mediaId, {
314
- origin: options.origin,
315
- token: options.token,
316
- fetchFn,
317
- });
318
- if (remoteCheck.exists === false) {
319
- // Purge the stale local record so the next run does not keep reusing a dead URL
320
- const staleState = options.cache.getMediaStateByMediaId(promoted.mediaId);
321
- if (staleState) {
322
- options.cache.deleteMediaState(staleState.storageKey);
323
- }
324
- throw new ApiError({
325
- message: `Image '${draftRef.filename}' was already promoted to '${promoted.promotedRelPath}' (media id ${promoted.mediaId}), but that media record no longer exists on remote.`,
326
- code: 'PROMOTED_MEDIA_NOT_FOUND_REMOTE',
327
- status: 404,
328
- endpoint: `/_emdash/api/media/${promoted.mediaId}`,
329
- suggestion: 'Restore the media on the EmDash side (then run "emit pull-media"), or place the image back under media/drafts/ and push again so Emit uploads a fresh record.',
330
- });
331
- }
332
- const detail = remoteCheck.exists === true ? remoteCheck.item : undefined;
333
- const promotedStorageKey = detail?.storageKey || promoted.storageKey;
334
- uploadedByAbsPath.set(draftRef.resolvedAbsPath, {
335
- mediaId: promoted.mediaId,
336
- storageKey: promotedStorageKey,
337
- url: toRootRelativeMediaUrl(detail?.url || promoted.url, promotedStorageKey, options.origin),
338
- localHash: '',
339
- contentHash: '',
340
- size: 0,
341
- mimeType: resolveImageMimeType(draftRef.filename) || 'image/png',
342
- filename: draftRef.filename,
343
- deduplicated: true,
344
- remoteVerification: remoteCheck.exists === true ? 'verified' : 'unknown',
345
- remoteVerificationNote: remoteCheck.exists === 'unknown' ? remoteCheck.reason : undefined,
346
- });
347
- continue;
348
- }
349
- const uploadRes = await uploadMediaFile({
350
- filePath: draftRef.resolvedAbsPath,
351
- filename: draftRef.filename,
352
- alt: draftRef.alt || undefined,
353
- origin: options.origin,
354
- token: options.token,
355
- refreshToken: options.refreshToken,
356
- cache: options.cache,
357
- fetchFn: options.fetchFn,
358
- });
359
- uploadedByAbsPath.set(draftRef.resolvedAbsPath, uploadRes);
360
- }
361
- const rewrittenBody = rewriteDraftImagesInBody(body, draftImages, uploadedByAbsPath);
362
- return { body: rewrittenBody, uploadedByAbsPath };
363
- }
364
- function throwRemoteArticleDeleted(collection, id, targetFilePath, endpoint) {
365
- throw new ApiError({
366
- endpoint,
367
- code: 'REMOTE_ARTICLE_DELETED',
368
- status: 404,
369
- message: `Article ${id} (${targetFilePath}) was deleted on the remote server.`,
370
- suggestion: [
371
- '1. Keep your edits and re-create as a new article: remove the `id:` field from frontmatter, then "emit push <file>".',
372
- `2. Discard local edits and untrack: "emit pull ${id} --force" (local copy is preserved under posts/conflicts/)`,
373
- `3. Delete with rescue backup: "emit delete ${targetFilePath} --discard-local-edits"`,
374
- ].join('\n '),
375
- });
376
- }
377
- /**
378
- * Pushes a local markdown article to remote EmDash with full preflight guards,
379
- * two-tier lock lease protection, and automatic 3-way merge on conflict.
380
- */
381
- export async function pushArticle(targetFilePath, options) {
382
- const fetchFn = createAuthedFetch(options);
383
- const fullPath = path.isAbsolute(targetFilePath)
384
- ? targetFilePath
385
- : path.join(options.workspaceRoot, targetFilePath);
386
- if (!fs.existsSync(fullPath)) {
387
- throw new Error(`Target file not found: ${targetFilePath}`);
388
- }
389
- const originalRelPath = path.relative(options.workspaceRoot, fullPath);
390
- // Rescue copies under "<collection>/conflicts/" are deliberately unpushable. They exist only to
391
- // retain divergent local content that a pull was about to overwrite; pushing one as-is would
392
- // replace the canonical article's remote title/slug/body with stale divergent content and
393
- // regenerate the duplicate-file-for-one-ULID state that quarantine exists to repair.
394
- if (isQuarantinedRelativePath(originalRelPath)) {
395
- throw new PushValidationError(`Refusing to push a preserved rescue copy: ${originalRelPath}`, [
396
- {
397
- code: 'QUARANTINED_COPY_NOT_PUSHABLE',
398
- message: `'${originalRelPath}' is a preserved rescue copy under "${CONFLICTS_SUBDIR}/". ` +
399
- 'Rescue copies hold stale divergent content captured before a pull overwrote it; ' +
400
- 'pushing one would overwrite the canonical article of the same ULID and recreate duplicate local files.',
401
- suggestion: 'Merge the valuable lines into the canonical article file (posts/draft/... or posts/published/...), then delete this copy. ' +
402
- 'To intentionally replace the remote version, copy the rescue file over the canonical path and push that canonical path instead.',
403
- },
404
- ]);
405
- }
406
- const rawContent = fs.readFileSync(fullPath, 'utf8');
407
- const parsed = parseMarkdown(rawContent, targetFilePath);
408
- const id = parsed.frontmatter.id;
409
- const collection = parsed.frontmatter.collection;
410
- // Retrieve sync state and base snapshot
411
- const syncState = options.cache.getSyncState(id);
412
- const baseSnapshot = options.cache.getBaseSnapshot(id);
413
- // Pre-load local taxonomy maps once for canonicalization, G4 guard, and remote sync
414
- const taxonomyMaps = loadLocalTaxonomyMaps(options.workspaceRoot);
415
- const resolvedTaxonomies = {};
416
- const canonicalizeTerms = (taxName, terms) => {
417
- const list = [];
418
- const canonicalized = terms.map((term) => {
419
- // 1. If term is a ULID in idToSlug
420
- if (taxonomyMaps.idToSlug[taxName]?.[term]) {
421
- const canonicalSlug = taxonomyMaps.idToSlug[taxName][term];
422
- list.push({ input: term, resolved: canonicalSlug, id: term });
423
- return canonicalSlug;
424
- }
425
- // 2. If term is in slugToId (can be canonical slug, decoded slug, or human name alias)
426
- const id = taxonomyMaps.slugToId[taxName]?.[term];
427
- if (id && taxonomyMaps.idToSlug[taxName]?.[id]) {
428
- const canonicalSlug = taxonomyMaps.idToSlug[taxName][id];
429
- if (term !== canonicalSlug) {
430
- list.push({ input: term, resolved: canonicalSlug, id });
431
- }
432
- return canonicalSlug;
433
- }
434
- return term;
435
- });
436
- if (list.length > 0) {
437
- resolvedTaxonomies[taxName === 'category' ? 'categories' : 'tags'] = list;
438
- }
439
- return canonicalized;
440
- };
441
- // Canonicalize frontmatter taxonomy terms in-place before preflight and payload creation
442
- if (parsed.frontmatter.taxonomies) {
443
- if (parsed.frontmatter.taxonomies.categories) {
444
- parsed.frontmatter.taxonomies.categories = canonicalizeTerms('category', parsed.frontmatter.taxonomies.categories);
445
- }
446
- if (parsed.frontmatter.taxonomies.tags) {
447
- parsed.frontmatter.taxonomies.tags = canonicalizeTerms('tag', parsed.frontmatter.taxonomies.tags);
448
- }
449
- }
450
- // Pre-build taxonomy candidates with slugToName for frontmatter serialization with inline '# <Name>' comments
451
- const candidates = options.candidates || loadLocalTaxonomyCandidates(options.workspaceRoot);
452
- // If knownTaxonomies is not explicitly passed, populate from local categories/ and tags/ (if present)
453
- let knownTaxonomies = options.knownTaxonomies;
454
- if (!knownTaxonomies) {
455
- const knownCats = Object.keys(taxonomyMaps.slugToId.category);
456
- const knownTags = Object.keys(taxonomyMaps.slugToId.tag);
457
- if (knownCats.length > 0 || knownTags.length > 0) {
458
- knownTaxonomies = {
459
- ...(knownCats.length > 0 ? { categories: knownCats } : {}),
460
- ...(knownTags.length > 0 ? { tags: knownTags } : {}),
461
- };
462
- }
463
- }
464
- // 1. Guard against blind re-push when article is marked delete_pending (G15)
465
- if (syncState?.deletePending === 1 && !options.revive && !options.force) {
466
- throw new ApiError({
467
- endpoint: `/_emdash/api/content/${collection}/${id}`,
468
- code: 'DELETE_PENDING_ARTICLE',
469
- status: 409,
470
- message: `Article ${id} (${targetFilePath}) is marked as delete-pending (previous remote deletion was unconfirmed).`,
471
- suggestion: `To intentionally revive and push this article to remote, pass --revive (or --force): "emit push ${targetFilePath} --revive"`,
472
- });
473
- }
474
- // 2. Preflight Guards (including G6: UNRESOLVED_CONFLICT_MARKERS)
475
- // Evaluated before taxonomy conflict pending so markers in markdown body are caught first
476
- const preflight = runPreflightChecks(targetFilePath, parsed, {
477
- allowPublish: options.allowPublish,
478
- knownTaxonomies,
479
- baseMarkdown: baseSnapshot?.bodyMarkdown,
480
- previousStatus: syncState?.status || 'draft',
481
- workspaceRoot: options.workspaceRoot,
482
- cache: options.cache,
483
- });
484
- if (!preflight.valid) {
485
- throw new PushValidationError(`Push blocked by Preflight Guards (${preflight.errors.length} error(s)).`, preflight.errors);
486
- }
487
- // Update parsed with cleaned body (e.g. trimmed duplicate heading)
488
- parsed.body = preflight.cleanedBody;
489
- // 3. Guard against blind re-push when taxonomy conflict is still pending (G16)
490
- const isTaxConflictPending = syncState?.taxonomyConflictPending !== undefined
491
- ? syncState.taxonomyConflictPending === 1
492
- : syncState?.conflictPending === 1 && (syncState?.bodyConflictPending ?? 0) === 0;
493
- if (isTaxConflictPending && !options.resolveTaxonomy && !options.force) {
494
- throw new ApiError({
495
- endpoint: `/_emdash/api/content/${collection}/${id}`,
496
- code: 'TAXONOMY_CONFLICT_PENDING',
497
- status: 409,
498
- message: `Article ${id} (${targetFilePath}) has an unresolved taxonomy conflict from a previous sync.`,
499
- suggestion: `Pass --resolve-taxonomy=union|local|remote (or --force) to explicitly resolve and confirm: "emit push ${targetFilePath} --resolve-taxonomy=union"`,
500
- });
501
- }
502
- // 4. Guard against blind re-push when previous taxonomy update failed (G17)
503
- if (syncState?.taxonomyApplyPending === 1 &&
504
- !options.allowUnappliedTaxonomies &&
505
- !options.force) {
506
- throw new ApiError({
507
- endpoint: `/_emdash/api/content/${collection}/${id}`,
508
- code: 'TAXONOMY_APPLY_PENDING',
509
- status: 409,
510
- message: `Article ${id} (${targetFilePath}) has an unconfirmed taxonomy assignment from a previous failed update.`,
511
- suggestion: `Run "emit pull-taxonomies" to synchronize taxonomy definitions, then re-push (or pass --allow-unapplied-taxonomies / --force).`,
512
- });
513
- }
514
- const currentRev = syncState?.remoteRev || baseSnapshot?.rev || '';
515
- const warnings = [...preflight.warnings];
516
- // Load local bylines map for converting frontmatter byline slugs to ULIDs
517
- let bylineSlugToId = options.bylineSlugToId;
518
- if (!bylineSlugToId) {
519
- const cachedMaps = options.cache.getBylineMaps();
520
- if (Object.keys(cachedMaps.slugToId).length > 0) {
521
- bylineSlugToId = cachedMaps.slugToId;
522
- }
523
- else {
524
- const freshMaps = await syncRemoteBylines({
525
- origin: options.origin,
526
- token: options.token,
527
- refreshToken: options.refreshToken,
528
- cache: options.cache,
529
- fetchFn: options.fetchFn,
530
- }).catch(() => ({ slugToId: {}, idToSlug: {} }));
531
- bylineSlugToId = freshMaps.slugToId;
532
- }
533
- }
534
- const effectiveOptions = {
535
- ...options,
536
- bylineSlugToId,
537
- };
538
- // 2. Determine whether this is a new article create (POST) or update (PUT)
539
- const isNewArticle = !syncState;
540
- // Helper to synchronize taxonomy assignments
541
- const syncArticleTaxonomies = async (articleId) => {
542
- const failures = [];
543
- if (!parsed.frontmatter.taxonomies)
544
- return failures;
545
- const taxMaps = taxonomyMaps;
546
- const updateTaxonomy = async (taxonomyName, termSlugsOrIds) => {
547
- const slugMap = taxMaps.slugToId[taxonomyName] || {};
548
- const idMap = taxMaps.idToSlug[taxonomyName] || {};
549
- const resolvedTermIds = Array.from(new Set(termSlugsOrIds
550
- .map((item) => {
551
- // 1. If item is already a known ULID, preserve it directly to avoid silent substitution
552
- if (idMap[item])
553
- return item;
554
- // 2. If item was canonicalized from a specific ULID or alias, preserve the bound term ID
555
- const tracked = resolvedTaxonomies[taxonomyName === 'category' ? 'categories' : 'tags'];
556
- const match = tracked?.find((t) => t.resolved === item);
557
- if (match?.id)
558
- return match.id;
559
- // 3. Fallback to slugMap
560
- return slugMap[item] || item;
561
- })
562
- .filter((tid) => Boolean(tid))));
563
- if (resolvedTermIds.length === 0) {
564
- // Check if remote previously had assignments in baseline
565
- const baseFm = baseSnapshot?.frontmatterYaml
566
- ? JSON.parse(baseSnapshot.frontmatterYaml)
567
- : null;
568
- const prevTerms = taxonomyName === 'category' ? baseFm?.taxonomies?.categories : baseFm?.taxonomies?.tags;
569
- if (prevTerms && prevTerms.length > 0) {
570
- if (!options.allowWipeTaxonomies && !options.force && !options.resolveTaxonomy) {
571
- throw new ApiError({
572
- endpoint: `/_emdash/api/content/${collection}/${articleId}/terms/${taxonomyName}`,
573
- code: 'DESTRUCTIVE_TAXONOMY_WIPE',
574
- status: 400,
575
- message: `Local article specified empty array for ${taxonomyName}, which would remove ${prevTerms.length} existing remote assignment(s).`,
576
- suggestion: `Pass --allow-wipe-taxonomies (or --force) to confirm wiping all ${taxonomyName} assignments.`,
577
- });
578
- }
579
- warnings.push(`🏷️ Cleared all ${prevTerms.length} ${taxonomyName} assignment(s) from remote (local specified empty array).`);
580
- }
581
- }
582
- const termsUrl = new URL(`/_emdash/api/content/${collection}/${articleId}/terms/${taxonomyName}`, options.origin).toString();
583
- const termRes = await fetchFn(termsUrl, {
584
- method: 'POST',
585
- headers: {
586
- Authorization: `Bearer ${options.token}`,
587
- 'Content-Type': 'application/json',
588
- },
589
- body: JSON.stringify({ termIds: resolvedTermIds }),
590
- });
591
- if (!termRes.ok) {
592
- const errBody = await termRes.text().catch(() => '');
593
- failures.push({
594
- taxonomy: taxonomyName,
595
- status: termRes.status,
596
- error: errBody || termRes.statusText,
597
- endpoint: termsUrl,
598
- });
599
- warnings.push(`Failed to update ${taxonomyName} terms: ${termRes.statusText} ${errBody}`);
600
- }
601
- };
602
- if (parsed.frontmatter.taxonomies.categories) {
603
- await updateTaxonomy('category', parsed.frontmatter.taxonomies.categories);
604
- }
605
- if (parsed.frontmatter.taxonomies.tags) {
606
- await updateTaxonomy('tag', parsed.frontmatter.taxonomies.tags);
607
- }
608
- return failures;
609
- };
610
- if (isNewArticle) {
611
- // [P2-P4] Upload/resolve standalone draft images and rewrite body
612
- const { body: rewrittenBody, uploadedByAbsPath } = await uploadAndRewriteDraftImages(parsed.body, preflight.draftImages, options, fetchFn);
613
- parsed.body = rewrittenBody;
614
- let payload = localToApiPayload(parsed, '', effectiveOptions);
615
- warnings.push(...preserveOrResolveFeaturedImage(payload, parsed.frontmatter.featuredImage, options.cache, id, options.workspaceRoot));
616
- const postUrl = new URL(`/_emdash/api/content/${collection}`, options.origin).toString();
617
- const res = await fetchFn(postUrl, {
618
- method: 'POST',
619
- headers: {
620
- Authorization: `Bearer ${options.token}`,
621
- 'Content-Type': 'application/json',
622
- },
623
- body: JSON.stringify(payload),
624
- });
625
- if (!res.ok) {
626
- const errBody = await res.text().catch(() => '');
627
- // Slug-conflict detection is code-first. A 422 or 409 alone is never grounds for the
628
- // collision guidance: an explicit code that names another failure (VALIDATION_ERROR,
629
- // ENTRY_LOCKED, FORBIDDEN, ...) must keep the generic suggestion, and a bare 409 on create
630
- // is far more likely a lock or race contention than a taken slug. Message text is only
631
- // consulted when the server sent no structured code at all - which is the case for EmDash
632
- // builds that predate `SLUG_CONFLICT`.
633
- const slugConflictDetail = 'slug-conflict-detail';
634
- let remoteSlug;
635
- let conflictSignal = false;
636
- try {
637
- const parsedJson = JSON.parse(errBody);
638
- const errObj = parsedJson?.error || parsedJson;
639
- remoteSlug = errObj?.details?.slug;
640
- if (errObj?.code === 'SLUG_CONFLICT') {
641
- conflictSignal = true;
642
- }
643
- else if (remoteSlug !== undefined) {
644
- // Structured slug metadata is the confirmed shape of a newer server: trust it.
645
- conflictSignal = slugConflictDetail;
646
- }
647
- else if (!errObj?.code && (res.status === 409 || res.status === 422)) {
648
- // No structured code available - fall back to message text only.
649
- conflictSignal = /slug/i.test(errObj?.message || '');
650
- }
651
- }
652
- catch {
653
- // Fallback for non-JSON error bodies: status alone is not enough, require slug wording.
654
- conflictSignal =
655
- (res.status === 409 || res.status === 422 ? /slug/i.test(errBody) : false) ||
656
- /slug.*(?:already exists|conflict)|unique constraint.*slug/i.test(errBody);
657
- }
658
- const isSlugConflict = conflictSignal === true || conflictSignal === slugConflictDetail;
659
- const suggestion = isSlugConflict
660
- ? [
661
- `An article with slug "${remoteSlug || parsed.frontmatter.slug}" already exists on remote.`,
662
- 'Change the "slug" field in frontmatter and re-run',
663
- `"emit push ${targetFilePath}" (Emit will automatically update the filename and cache).`,
664
- 'Note: EmDash keeps slugs reserved for trashed entries too, so a slug freed by deletion may still be taken.',
665
- ].join(' ')
666
- : 'Check if an article with this slug already exists or if token has create permissions.';
667
- throw new ApiError({
668
- message: `Create push failed: ${res.statusText} - ${errBody}`,
669
- code: 'PUSH_CREATE_FAILED',
670
- status: res.status,
671
- endpoint: postUrl,
672
- suggestion,
673
- });
674
- }
675
- const createdJson = await res.json();
676
- const createdItem = unwrapItem(createdJson);
677
- const actualId = String(createdItem.id || id);
678
- const newRev = extractRev(createdJson, '1');
679
- // Update parsed frontmatter with the actual ID returned by EmDash
680
- parsed.frontmatter.id = actualId;
681
- // Sync taxonomies for newly created article
682
- const taxFailures = await syncArticleTaxonomies(actualId);
683
- const hasTaxFailures = taxFailures.length > 0;
684
- const currentPrefix = extractDatePrefixFromPath(fullPath);
685
- const dateInput = parsed.frontmatter.publishedAt || currentPrefix || createdItem.createdAt || null;
686
- const newRelPath = computeArticleRelativePath(parsed.frontmatter.collection, parsed.frontmatter.status, actualId, parsed.frontmatter.title || parsed.frontmatter.slug, dateInput);
687
- const newFullPath = path.join(options.workspaceRoot, newRelPath);
688
- const serializedCanonical = serializeMarkdown(parsed.frontmatter, parsed.body, candidates);
689
- // [P6] Promote uploaded draft images from media/drafts/ into media/ and commit media_state.
690
- // Promotion must stay after the remote POST (a failed create never promotes a local image), but it must run
691
- // *before* the article is localized below: `localizeArticleMediaRefs` only rewrites a reference whose
692
- // storageKey already has a real mirror under media/, so promoting afterwards would leave a freshly uploaded
693
- // draft image spelled as a canonical remote URL on disk even with localMediaRefs enabled.
694
- finalizePromotedDraftImages(preflight.draftImages, uploadedByAbsPath, options, warnings);
695
- // `parsed.body` carries the canonical URL because preflight rewrote every local reference. The file that
696
- // lands on disk must carry the workspace's own spelling again, or a push would silently re-canonicalize
697
- // the article and the next status pass would report an edit nobody made. The baseline keeps the canonical
698
- // body untouched so `diff` and the 3-way merge stay byte-identical to what a remote pull would write.
699
- // An already-tracked file keeps the spelling its bytes already carry; only a fresh article (or one whose
700
- // path just changed, which changes the depth) is written in the article-relative form.
701
- const serializedContent = options.localMediaRefs
702
- ? localizeArticleMediaRefs(serializedCanonical, {
703
- workspaceRoot: options.workspaceRoot,
704
- articleRelPath: newRelPath,
705
- fs,
706
- preferLegacyRef: carriesDepthFreeMediaRef(rawContent),
707
- })
708
- : serializedCanonical;
709
- const newHash = calculateHash(serializedContent);
710
- // Transition status to published if requested and permitted
711
- let finalRev = newRev;
712
- if (parsed.frontmatter.status === 'published' && options.allowPublish) {
713
- finalRev = await transitionContentStatus(collection, actualId, 'published', newRev, parsed.frontmatter.publishedAt, options, fetchFn);
714
- }
715
- // Save newly serialized file (with actual server ID) and clean up provisional file
716
- fs.mkdirSync(path.dirname(newFullPath), { recursive: true });
717
- fs.writeFileSync(newFullPath, serializedContent, 'utf8');
718
- if (fullPath !== newFullPath && fs.existsSync(fullPath)) {
719
- try {
720
- fs.unlinkSync(fullPath);
721
- }
722
- catch (unlinkErr) {
723
- warnings.push(`Failed to remove provisional file at ${fullPath}: ${unlinkErr.message}.`);
724
- }
725
- }
726
- const finalTaxHash = hasTaxFailures
727
- ? null
728
- : computeTaxonomyHash(parsed.frontmatter.taxonomies?.categories, parsed.frontmatter.taxonomies?.tags);
729
- // Update Base snapshot and Sync state
730
- options.cache.saveBaseSnapshot({
731
- id: actualId,
732
- rev: finalRev,
733
- bodyMarkdown: parsed.body,
734
- frontmatterYaml: JSON.stringify(parsed.frontmatter),
735
- hash: newHash,
736
- savedAt: Math.floor(Date.now() / 1000),
737
- });
738
- options.cache.saveSyncState({
739
- id: actualId,
740
- localFilePath: newRelPath,
741
- localHash: newHash,
742
- remoteRev: finalRev,
743
- status: parsed.frontmatter.status,
744
- slug: parsed.frontmatter.slug,
745
- lastSyncedAt: Math.floor(Date.now() / 1000),
746
- conflictPending: 0,
747
- bodyConflictPending: 0,
748
- taxonomyConflictPending: 0,
749
- conflictCopyHash: null,
750
- conflictCopyPath: null,
751
- conflictAt: null,
752
- remoteTaxonomyHash: finalTaxHash,
753
- taxonomyApplyPending: hasTaxFailures ? 1 : 0,
754
- });
755
- if (hasTaxFailures) {
756
- throw new ApiError({
757
- endpoint: taxFailures[0].endpoint,
758
- code: 'TAXONOMY_UPDATE_FAILED',
759
- status: taxFailures[0].status || 400,
760
- message: `Failed to update ${taxFailures.map((f) => f.taxonomy).join(', ')} terms on remote: ${taxFailures.map((f) => f.error).join('; ')}`,
761
- suggestion: 'Run "emit pull-taxonomies" to update local definitions, verify taxonomy permissions, and retry push.',
762
- });
763
- }
764
- return {
765
- success: true,
766
- id: actualId,
767
- rev: finalRev,
768
- filePath: newRelPath,
769
- ...(actualId !== id ? { previousId: id } : {}),
770
- ...(originalRelPath !== newRelPath ? { previousFilePath: originalRelPath } : {}),
771
- ...(Object.keys(resolvedTaxonomies).length > 0 ? { resolvedTaxonomies } : {}),
772
- conflict: false,
773
- warnings,
774
- };
775
- }
776
- // Existing article: execute within full 7-minute entry lock lease (B-3: acquire lock BEFORE uploading media)
777
- return await withEntryLock(collection, id, options, async () => {
778
- // [P2-P4] Upload/resolve standalone draft images and rewrite body after entry lock is acquired
779
- const { body: rewrittenBody, uploadedByAbsPath } = await uploadAndRewriteDraftImages(parsed.body, preflight.draftImages, options, fetchFn);
780
- parsed.body = rewrittenBody;
781
- let payload = localToApiPayload(parsed, currentRev, effectiveOptions);
782
- warnings.push(...preserveOrResolveFeaturedImage(payload, parsed.frontmatter.featuredImage, options.cache, id, options.workspaceRoot));
783
- const putUrl = new URL(`/_emdash/api/content/${collection}/${id}`, options.origin).toString();
784
- // R1: Pre-PUT Taxonomy Audit to detect out-of-band remote taxonomy changes
785
- let remoteTaxAudit = null;
786
- let taxonomyDivergenceDetected = false;
787
- const baseFm = baseSnapshot?.frontmatterYaml
788
- ? JSON.parse(baseSnapshot.frontmatterYaml)
789
- : null;
790
- const hasLocalTaxonomies = Boolean(parsed.frontmatter.taxonomies &&
791
- (parsed.frontmatter.taxonomies.categories !== undefined ||
792
- parsed.frontmatter.taxonomies.tags !== undefined));
793
- const hasBaseTaxonomies = Boolean(baseFm?.taxonomies && (baseFm.taxonomies.categories || baseFm.taxonomies.tags));
794
- if ((hasLocalTaxonomies || hasBaseTaxonomies) && !options.noTaxonomyAudit) {
795
- remoteTaxAudit = await fetchAssignedTaxonomies({
796
- origin: options.origin,
797
- token: options.token,
798
- collection,
799
- id,
800
- fetchFn,
801
- });
802
- if (remoteTaxAudit.categories === null && remoteTaxAudit.tags === null) {
803
- warnings.push('⚠️ Remote taxonomy state unknown (could not audit remote terms).');
804
- }
805
- else {
806
- if (remoteTaxAudit.categories === null) {
807
- warnings.push('⚠️ Remote category taxonomy state unknown (could not audit categories).');
808
- }
809
- if (remoteTaxAudit.tags === null) {
810
- warnings.push('⚠️ Remote tag taxonomy state unknown (could not audit tags).');
811
- }
812
- const baselineTaxHash = syncState?.remoteTaxonomyHash ||
813
- (baseFm?.taxonomies
814
- ? computeTaxonomyHash(baseFm.taxonomies.categories, baseFm.taxonomies.tags)
815
- : null);
816
- const currentRemoteCats = remoteTaxAudit.categories !== null
817
- ? remoteTaxAudit.categories
818
- : baseFm?.taxonomies?.categories || [];
819
- const currentRemoteTags = remoteTaxAudit.tags !== null ? remoteTaxAudit.tags : baseFm?.taxonomies?.tags || [];
820
- const currentRemoteTaxHash = computeTaxonomyHash(currentRemoteCats, currentRemoteTags);
821
- if (baselineTaxHash && currentRemoteTaxHash !== baselineTaxHash) {
822
- taxonomyDivergenceDetected = true;
823
- }
824
- }
825
- }
826
- let res = taxonomyDivergenceDetected
827
- ? {
828
- status: 409,
829
- ok: false,
830
- json: async () => ({ error: { code: 'TAXONOMY_DIVERGENCE' } }),
831
- text: async () => '',
832
- }
833
- : await fetchFn(putUrl, {
834
- method: 'PUT',
835
- headers: {
836
- Authorization: `Bearer ${options.token}`,
837
- 'Content-Type': 'application/json',
838
- },
839
- body: JSON.stringify(payload),
840
- });
841
- if (!taxonomyDivergenceDetected && (res.status === 404 || res.status === 410)) {
842
- throwRemoteArticleDeleted(collection, id, targetFilePath, putUrl);
843
- }
844
- // 3. Handle 409 Conflict
845
- if (res.status === 409) {
846
- const errJson = (await res.json().catch(() => ({})));
847
- const errorCode = errJson.error?.code || 'CONFLICT';
848
- if (errorCode === 'ENTRY_LOCKED') {
849
- throw new ApiError({
850
- message: `Entry is currently locked by another user. (${errJson.error?.message || ''})`,
851
- code: 'ENTRY_LOCKED',
852
- status: 409,
853
- endpoint: putUrl,
854
- suggestion: 'Wait for the other user to finish editing or release their lock before retrying.',
855
- });
856
- }
857
- // True revision mismatch conflict or taxonomy divergence: fetch remote latest and run Hybrid 3-Way Merge
858
- const getUrl = new URL(`/_emdash/api/content/${collection}/${id}`, options.origin).toString();
859
- const getRes = await fetchFn(getUrl, {
860
- method: 'GET',
861
- headers: {
862
- Authorization: `Bearer ${options.token}`,
863
- Accept: 'application/json',
864
- },
865
- });
866
- if (!getRes.ok) {
867
- if (getRes.status === 404 || getRes.status === 410) {
868
- throwRemoteArticleDeleted(collection, id, targetFilePath, getUrl);
869
- }
870
- throw new ApiError({
871
- message: `Failed to fetch latest remote version during conflict resolution: ${getRes.statusText}`,
872
- code: 'CONFLICT_REMOTE_FETCH_FAILED',
873
- status: getRes.status,
874
- endpoint: getUrl,
875
- suggestion: 'Check remote server availability to resolve conflict.',
876
- });
877
- }
878
- const remoteItem = (await getRes.json());
879
- const remoteParsed = apiResponseToLocal(remoteItem, options);
880
- // Fetch remote taxonomy assignments (reuse remoteTaxAudit if available)
881
- const remoteTax = remoteTaxAudit ||
882
- (await fetchAssignedTaxonomies({
883
- origin: options.origin,
884
- token: options.token,
885
- collection,
886
- id,
887
- fetchFn,
888
- }));
889
- if (!remoteParsed.frontmatter.taxonomies) {
890
- remoteParsed.frontmatter.taxonomies = {};
891
- }
892
- if (remoteTax.categories !== null) {
893
- remoteParsed.frontmatter.taxonomies.categories = remoteTax.categories;
894
- }
895
- else if (baseSnapshot) {
896
- const baseFm = JSON.parse(baseSnapshot.frontmatterYaml);
897
- if (baseFm.taxonomies?.categories) {
898
- remoteParsed.frontmatter.taxonomies.categories = baseFm.taxonomies.categories;
899
- }
900
- }
901
- if (remoteTax.tags !== null) {
902
- remoteParsed.frontmatter.taxonomies.tags = remoteTax.tags;
903
- }
904
- else if (baseSnapshot) {
905
- const baseFm = JSON.parse(baseSnapshot.frontmatterYaml);
906
- if (baseFm.taxonomies?.tags) {
907
- remoteParsed.frontmatter.taxonomies.tags = baseFm.taxonomies.tags;
908
- }
909
- }
910
- // Perform Hybrid 3-Way Merge
911
- const baseInfo = baseSnapshot
912
- ? {
913
- frontmatter: JSON.parse(baseSnapshot.frontmatterYaml),
914
- body: baseSnapshot.bodyMarkdown,
915
- }
916
- : null;
917
- const mergeResult = hybrid3WayMerge(parsed, baseInfo, {
918
- frontmatter: remoteParsed.frontmatter,
919
- body: remoteParsed.body,
920
- }, {
921
- strategy: options.resolveTaxonomy,
922
- });
923
- warnings.push(...mergeResult.warnings);
924
- // Check destructive taxonomy wipe guard during 3-way merge (exempt if strategy is explicitly specified)
925
- if (!options.resolveTaxonomy &&
926
- mergeResult.wipeWarnings &&
927
- mergeResult.wipeWarnings.length > 0) {
928
- if (!options.allowWipeTaxonomies && !options.force) {
929
- const details = mergeResult.wipeWarnings
930
- .map((w) => `${w.name}: clearing ${w.remoteTerms.length} assignment(s)`)
931
- .join(', ');
932
- throw new ApiError({
933
- endpoint: `/_emdash/api/content/${collection}/${id}`,
934
- code: 'DESTRUCTIVE_TAXONOMY_WIPE',
935
- status: 400,
936
- message: `Local article specified empty array for taxonomies (${details}), which would wipe existing remote assignments.`,
937
- suggestion: `Pass --allow-wipe-taxonomies (or --force) to confirm clearing: "emit push ${targetFilePath} --allow-wipe-taxonomies"`,
938
- });
939
- }
940
- }
941
- // Log clean auto-merged taxonomy changes
942
- if (mergeResult.autoMergedTaxonomies && mergeResult.autoMergedTaxonomies.length > 0) {
943
- for (const item of mergeResult.autoMergedTaxonomies) {
944
- const parts = [];
945
- if (item.remoteAdded.length > 0)
946
- parts.push(`+${item.remoteAdded.join(', ')}`);
947
- if (item.remoteRemoved.length > 0)
948
- parts.push(`-${item.remoteRemoved.join(', ')}`);
949
- warnings.push(`🏷️ Merged remote taxonomy change(s) for ${item.name}: ${parts.join(' ')}`);
950
- }
951
- }
952
- // Log strategy adoptions
953
- if (mergeResult.strategyAdoptions && mergeResult.strategyAdoptions.length > 0) {
954
- for (const item of mergeResult.strategyAdoptions) {
955
- const parts = [];
956
- if (item.discardedAdded.length > 0)
957
- parts.push(`+${item.discardedAdded.join(', ')}`);
958
- if (item.discardedRemoved.length > 0)
959
- parts.push(`-${item.discardedRemoved.join(', ')}`);
960
- const discardedStr = parts.length > 0 ? `: discarded ${parts.join(' ')}` : '';
961
- warnings.push(`🏷️ Resolved '${item.name}' with strategy '${item.side}'${discardedStr}.`);
962
- }
963
- }
964
- // Fallback post-merge resolution only if strategy was NOT specified and taxonomy conflicts occurred
965
- if (!options.resolveTaxonomy &&
966
- mergeResult.taxonomyConflicts &&
967
- mergeResult.taxonomyConflicts.length > 0) {
968
- if (options.force) {
969
- const strategy = 'union';
970
- const unionTaxonomies = {};
971
- const allTaxNames = new Set([
972
- ...Object.keys(parsed.frontmatter.taxonomies || {}),
973
- ...Object.keys(remoteParsed.frontmatter.taxonomies || {}),
974
- ]);
975
- for (const tName of allTaxNames) {
976
- const lTerms = parsed.frontmatter.taxonomies?.[tName] || [];
977
- const rTerms = remoteParsed.frontmatter.taxonomies?.[tName] || [];
978
- unionTaxonomies[tName] = Array.from(new Set([...lTerms, ...rTerms]));
979
- }
980
- mergeResult.frontmatter.taxonomies = unionTaxonomies;
981
- warnings.push(`Resolved taxonomy conflicts using strategy '${strategy}'.`);
982
- }
983
- else {
984
- mergeResult.hasConflict = true;
985
- warnings.push(`Conflict on taxonomies (${mergeResult.taxonomyConflicts.join(', ')}): local and remote changes could not be cleanly merged. Push aborted to prevent data loss.`);
986
- }
987
- }
988
- if (mergeResult.hasConflict) {
989
- const isTaxonomyConflict = Boolean(mergeResult.taxonomyConflicts && mergeResult.taxonomyConflicts.length > 0);
990
- const hasBodyConflict = Boolean(mergeResult.body.includes('<<<<<<< LOCAL') || mergeResult.body.includes('<<<<<<< local'));
991
- const isTaxonomyOnlyConflict = isTaxonomyConflict && !hasBodyConflict;
992
- const conflictedMarkdown = serializeMarkdown(mergeResult.frontmatter, mergeResult.body, options.candidates);
993
- let quarantineRel = null;
994
- // Only write conflict markers into the article body if body actually has conflicts
995
- if (!isTaxonomyOnlyConflict) {
996
- const currentDisk = fs.readFileSync(fullPath, 'utf8');
997
- quarantineRel = quarantineLocalCopy(options.workspaceRoot, collection, targetFilePath, currentDisk, { isForce: false });
998
- fs.writeFileSync(fullPath, conflictedMarkdown, 'utf8');
999
- }
1000
- const conflictedHash = calculateHash(conflictedMarkdown);
1001
- // Update sync state:
1002
- // Do NOT overwrite localHash with marker hash, so `status` remains clean when markers are cleaned up!
1003
- options.cache.saveSyncState({
1004
- id,
1005
- localFilePath: targetFilePath,
1006
- localHash: syncState?.localHash || '',
1007
- remoteRev: remoteParsed.rev,
1008
- status: parsed.frontmatter.status,
1009
- slug: parsed.frontmatter.slug,
1010
- lastSyncedAt: Math.floor(Date.now() / 1000),
1011
- conflictPending: 1,
1012
- bodyConflictPending: hasBodyConflict ? 1 : 0,
1013
- taxonomyConflictPending: isTaxonomyConflict ? 1 : 0,
1014
- conflictCopyHash: conflictedHash,
1015
- conflictCopyPath: quarantineRel,
1016
- conflictAt: Math.floor(Date.now() / 1000),
1017
- remoteTaxonomyHash: computeTaxonomyHash(remoteParsed.frontmatter.taxonomies?.categories, remoteParsed.frontmatter.taxonomies?.tags),
1018
- taxonomyApplyPending: 0,
1019
- });
1020
- options.cache.saveBaseSnapshot({
1021
- id,
1022
- rev: remoteParsed.rev,
1023
- bodyMarkdown: remoteParsed.body,
1024
- frontmatterYaml: JSON.stringify(remoteParsed.frontmatter),
1025
- hash: calculateHash(serializeMarkdown(remoteParsed.frontmatter, remoteParsed.body, candidates)),
1026
- savedAt: Math.floor(Date.now() / 1000),
1027
- });
1028
- return {
1029
- success: false,
1030
- id,
1031
- rev: remoteParsed.rev,
1032
- filePath: targetFilePath,
1033
- conflict: true,
1034
- taxonomyConflict: isTaxonomyOnlyConflict,
1035
- warnings,
1036
- };
1037
- }
1038
- // Auto-merged cleanly: retry PUT with remote's _rev
1039
- parsed.frontmatter = mergeResult.frontmatter;
1040
- parsed.body = mergeResult.body;
1041
- payload = localToApiPayload(parsed, remoteParsed.rev, effectiveOptions);
1042
- warnings.push(...preserveOrResolveFeaturedImage(payload, parsed.frontmatter.featuredImage, options.cache, id, options.workspaceRoot));
1043
- res = await fetchFn(putUrl, {
1044
- method: 'PUT',
1045
- headers: {
1046
- Authorization: `Bearer ${options.token}`,
1047
- 'Content-Type': 'application/json',
1048
- },
1049
- body: JSON.stringify(payload),
1050
- });
1051
- if (!res.ok) {
1052
- if (res.status === 404 || res.status === 410) {
1053
- throwRemoteArticleDeleted(collection, id, targetFilePath, putUrl);
1054
- }
1055
- const errText = await res.text().catch(() => '');
1056
- // Check if server rejected the merged taxonomy union or strategy (e.g. single category limit)
1057
- if ((res.status === 400 || res.status === 422) &&
1058
- ((mergeResult.autoMergedTaxonomies && mergeResult.autoMergedTaxonomies.length > 0) ||
1059
- (mergeResult.strategyAdoptions && mergeResult.strategyAdoptions.length > 0))) {
1060
- const wasStrategy = mergeResult.strategyAdoptions && mergeResult.strategyAdoptions.length > 0;
1061
- throw new ApiError({
1062
- message: `Remote server rejected the merged taxonomy union (HTTP ${res.status}): ${errText || res.statusText}`,
1063
- code: 'TAXONOMY_UNION_REJECTED',
1064
- status: res.status,
1065
- endpoint: putUrl,
1066
- suggestion: wasStrategy
1067
- ? 'The remote server rejected the taxonomy adopted by your strategy (e.g. single-category constraint). Switch strategy with "--resolve-taxonomy=remote" or edit the frontmatter manually before pushing.'
1068
- : 'The remote server enforces constraints that reject combining local and remote taxonomy terms (e.g. single-category constraint). Resolve by explicitly choosing a side: "emit push <file> --resolve-taxonomy=local" (or remote), or edit the frontmatter manually.',
1069
- });
1070
- }
1071
- throw new ApiError({
1072
- message: `Retry push after auto-merge failed: ${res.statusText}${errText ? ` - ${errText}` : ''}`,
1073
- code: 'PUSH_RETRY_FAILED',
1074
- status: res.status,
1075
- endpoint: putUrl,
1076
- suggestion: 'Another change might have been committed in the meantime. Try pulling again.',
1077
- });
1078
- }
1079
- }
1080
- if (!res.ok) {
1081
- if (res.status === 404 || res.status === 410) {
1082
- throwRemoteArticleDeleted(collection, id, targetFilePath, putUrl);
1083
- }
1084
- const errText = await res.text().catch(() => '');
1085
- throw new ApiError({
1086
- message: `Push failed: ${res.statusText}${errText ? ` - ${errText}` : ''}`,
1087
- code: 'PUSH_FAILED',
1088
- status: res.status,
1089
- endpoint: putUrl,
1090
- suggestion: 'Verify network connectivity, token permissions, and article status.',
1091
- });
1092
- }
1093
- const updatedJson = await res.json().catch(() => ({}));
1094
- const newRev = extractRev(updatedJson, currentRev);
1095
- // 4. Post-push updates & Path tracking
1096
- // Sync taxonomies on updated article
1097
- const taxFailures = await syncArticleTaxonomies(id);
1098
- const hasTaxFailures = taxFailures.length > 0;
1099
- const currentPrefix = extractDatePrefixFromPath(fullPath);
1100
- const existingSync = options.cache.getSyncState(parsed.frontmatter.id);
1101
- const dateInput = parsed.frontmatter.publishedAt ||
1102
- currentPrefix ||
1103
- (existingSync?.lastSyncedAt ? new Date(existingSync.lastSyncedAt * 1000) : null);
1104
- const newRelPath = computeArticleRelativePath(parsed.frontmatter.collection, parsed.frontmatter.status, parsed.frontmatter.id, parsed.frontmatter.title || parsed.frontmatter.slug, dateInput);
1105
- const newFullPath = path.join(options.workspaceRoot, newRelPath);
1106
- const serializedCanonical = serializeMarkdown(parsed.frontmatter, parsed.body, candidates);
1107
- // [P6] Promote uploaded draft images from media/drafts/ into media/ and commit media_state — after the
1108
- // remote PUT and its conflict handling (a failed or conflicted push never promotes), but before the
1109
- // localization below, for the same reason as on the create path: a mirror that does not exist yet on disk
1110
- // can never be localized into an article-relative reference.
1111
- finalizePromotedDraftImages(preflight.draftImages, uploadedByAbsPath, options, warnings);
1112
- // Same disk spelling rule as the clean-push path: keep the workspace's own local references on disk while
1113
- // the canonical form goes to remote and into the baseline. A file that already carries the depth-free form
1114
- // keeps it; a path change (draft→published) re-spells at the new depth.
1115
- const serializedContent = options.localMediaRefs
1116
- ? localizeArticleMediaRefs(serializedCanonical, {
1117
- workspaceRoot: options.workspaceRoot,
1118
- articleRelPath: newRelPath,
1119
- fs,
1120
- preferLegacyRef: carriesDepthFreeMediaRef(rawContent),
1121
- })
1122
- : serializedCanonical;
1123
- const newHash = calculateHash(serializedContent);
1124
- // Transition status (publish or unpublish) via dedicated endpoints if needed
1125
- let finalRev = newRev;
1126
- const previousStatus = syncState?.status || 'draft';
1127
- if (parsed.frontmatter.status === 'published' && options.allowPublish) {
1128
- finalRev = await transitionContentStatus(collection, id, 'published', newRev, parsed.frontmatter.publishedAt, options, fetchFn);
1129
- }
1130
- else if (parsed.frontmatter.status === 'draft' && previousStatus === 'published') {
1131
- finalRev = await transitionContentStatus(collection, id, 'draft', newRev, null, options, fetchFn);
1132
- }
1133
- // Only move/rename file on disk AFTER remote update & status transition have succeeded!
1134
- if (fullPath !== newFullPath) {
1135
- fs.mkdirSync(path.dirname(newFullPath), { recursive: true });
1136
- fs.writeFileSync(newFullPath, serializedContent, 'utf8');
1137
- if (fs.existsSync(fullPath)) {
1138
- try {
1139
- fs.unlinkSync(fullPath);
1140
- }
1141
- catch (unlinkErr) {
1142
- warnings.push(`Failed to remove old file at ${fullPath} after path transition: ${unlinkErr.message}. Please remove it manually to prevent duplicates.`);
1143
- }
1144
- }
1145
- }
1146
- else {
1147
- fs.writeFileSync(fullPath, serializedContent, 'utf8');
1148
- }
1149
- // Update Base snapshot
1150
- const savedFrontmatter = { ...parsed.frontmatter };
1151
- if (hasTaxFailures && baseFm?.taxonomies) {
1152
- savedFrontmatter.taxonomies = { ...baseFm.taxonomies };
1153
- }
1154
- const finalTaxHash = hasTaxFailures
1155
- ? syncState?.remoteTaxonomyHash ||
1156
- (baseFm?.taxonomies
1157
- ? computeTaxonomyHash(baseFm.taxonomies.categories, baseFm.taxonomies.tags)
1158
- : null)
1159
- : computeTaxonomyHash(parsed.frontmatter.taxonomies?.categories, parsed.frontmatter.taxonomies?.tags);
1160
- options.cache.saveBaseSnapshot({
1161
- id,
1162
- rev: finalRev,
1163
- bodyMarkdown: parsed.body,
1164
- frontmatterYaml: JSON.stringify(savedFrontmatter),
1165
- hash: newHash,
1166
- savedAt: Math.floor(Date.now() / 1000),
1167
- });
1168
- // Update sync state
1169
- options.cache.saveSyncState({
1170
- id,
1171
- localFilePath: newRelPath,
1172
- localHash: newHash,
1173
- remoteRev: finalRev,
1174
- status: parsed.frontmatter.status,
1175
- slug: parsed.frontmatter.slug,
1176
- lastSyncedAt: Math.floor(Date.now() / 1000),
1177
- conflictPending: hasTaxFailures ? (syncState?.conflictPending ?? 0) : 0,
1178
- bodyConflictPending: hasTaxFailures ? (syncState?.bodyConflictPending ?? 0) : 0,
1179
- taxonomyConflictPending: hasTaxFailures ? (syncState?.taxonomyConflictPending ?? 0) : 0,
1180
- conflictCopyHash: hasTaxFailures ? syncState?.conflictCopyHash : null,
1181
- conflictCopyPath: hasTaxFailures ? syncState?.conflictCopyPath : null,
1182
- conflictAt: hasTaxFailures ? syncState?.conflictAt : null,
1183
- remoteTaxonomyHash: finalTaxHash,
1184
- taxonomyApplyPending: hasTaxFailures ? 1 : 0,
1185
- });
1186
- if (hasTaxFailures) {
1187
- throw new ApiError({
1188
- endpoint: taxFailures[0].endpoint,
1189
- code: 'TAXONOMY_UPDATE_FAILED',
1190
- status: taxFailures[0].status || 400,
1191
- message: `Failed to update ${taxFailures.map((f) => f.taxonomy).join(', ')} terms on remote: ${taxFailures.map((f) => f.error).join('; ')}`,
1192
- suggestion: 'Run "emit pull-taxonomies" to update local definitions, verify taxonomy permissions, and retry push.',
1193
- });
1194
- }
1195
- updateConfigSyncedVersion(options.workspaceRoot);
1196
- return {
1197
- success: true,
1198
- id,
1199
- rev: finalRev,
1200
- filePath: newRelPath,
1201
- ...(originalRelPath !== newRelPath ? { previousFilePath: originalRelPath } : {}),
1202
- ...(Object.keys(resolvedTaxonomies).length > 0 ? { resolvedTaxonomies } : {}),
1203
- conflict: false,
1204
- warnings,
1205
- };
1206
- });
1207
- }
25
+ export * from './push/index.js';
1208
26
  //# sourceMappingURL=push.js.map