@micacms/runtime 0.5.0 → 0.6.0-rc.1

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 (87) hide show
  1. package/README.md +3 -1
  2. package/dist/app.js +1 -1
  3. package/dist/app.js.map +1 -1
  4. package/dist/config.d.ts +13 -13
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +9 -9
  7. package/dist/config.js.map +1 -1
  8. package/dist/context.js +2 -2
  9. package/dist/context.js.map +1 -1
  10. package/dist/http.d.ts +1 -3
  11. package/dist/http.d.ts.map +1 -1
  12. package/dist/http.js +2 -9
  13. package/dist/http.js.map +1 -1
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/lib/errors.d.ts +1 -2
  19. package/dist/lib/errors.d.ts.map +1 -1
  20. package/dist/lib/errors.js +0 -2
  21. package/dist/lib/errors.js.map +1 -1
  22. package/dist/lib/id.d.ts +0 -10
  23. package/dist/lib/id.d.ts.map +1 -1
  24. package/dist/lib/id.js +0 -50
  25. package/dist/lib/id.js.map +1 -1
  26. package/dist/lib/rate-limit.d.ts +0 -2
  27. package/dist/lib/rate-limit.d.ts.map +1 -1
  28. package/dist/lib/rate-limit.js +0 -3
  29. package/dist/lib/rate-limit.js.map +1 -1
  30. package/dist/middleware/index.d.ts +1 -1
  31. package/dist/middleware/index.js +2 -2
  32. package/dist/middleware/index.js.map +1 -1
  33. package/dist/routes/admin-content.d.ts.map +1 -1
  34. package/dist/routes/admin-content.js +3 -2
  35. package/dist/routes/admin-content.js.map +1 -1
  36. package/dist/routes/admin-system.js +4 -4
  37. package/dist/routes/admin-system.js.map +1 -1
  38. package/dist/schema.d.ts +6 -0
  39. package/dist/schema.d.ts.map +1 -1
  40. package/dist/schema.js +19 -1
  41. package/dist/schema.js.map +1 -1
  42. package/dist/services/changes.d.ts.map +1 -1
  43. package/dist/services/changes.js +89 -13
  44. package/dist/services/changes.js.map +1 -1
  45. package/dist/services/content-store.d.ts.map +1 -1
  46. package/dist/services/content-store.js +9 -10
  47. package/dist/services/content-store.js.map +1 -1
  48. package/dist/services/staging.d.ts.map +1 -1
  49. package/dist/services/staging.js +7 -4
  50. package/dist/services/staging.js.map +1 -1
  51. package/dist/workspace/tree.d.ts +6 -0
  52. package/dist/workspace/tree.d.ts.map +1 -1
  53. package/dist/workspace/tree.js +28 -0
  54. package/dist/workspace/tree.js.map +1 -1
  55. package/package.json +12 -4
  56. package/src/app.ts +127 -0
  57. package/src/config.ts +113 -0
  58. package/src/context.ts +167 -0
  59. package/src/http.ts +46 -0
  60. package/src/index.ts +141 -0
  61. package/src/lib/errors.ts +84 -0
  62. package/src/lib/file-type.ts +108 -0
  63. package/src/lib/id.ts +6 -0
  64. package/src/lib/logger.ts +87 -0
  65. package/src/lib/ports.ts +17 -0
  66. package/src/lib/rate-limit.ts +72 -0
  67. package/src/lib/schema-stats.ts +183 -0
  68. package/src/lib/version.ts +29 -0
  69. package/src/middleware/index.ts +234 -0
  70. package/src/routes/admin-content.ts +197 -0
  71. package/src/routes/admin-media.ts +96 -0
  72. package/src/routes/admin-spa.ts +82 -0
  73. package/src/routes/admin-staging.ts +126 -0
  74. package/src/routes/admin-system.ts +146 -0
  75. package/src/routes/health.ts +67 -0
  76. package/src/schema.ts +217 -0
  77. package/src/server.ts +68 -0
  78. package/src/services/changes.ts +605 -0
  79. package/src/services/content-store.ts +492 -0
  80. package/src/services/media.ts +229 -0
  81. package/src/services/staging.ts +330 -0
  82. package/src/workspace/developer.ts +46 -0
  83. package/src/workspace/files.ts +243 -0
  84. package/src/workspace/git.ts +108 -0
  85. package/src/workspace/locks.ts +103 -0
  86. package/src/workspace/policy.ts +77 -0
  87. package/src/workspace/tree.ts +314 -0
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Media uploads. Every upload is checked here for size and for its real content
3
+ * type, read from the bytes. An upload is a file in the content repository; its
4
+ * name is slugified and unique in its own directory.
5
+ */
6
+
7
+ import path from "node:path";
8
+
9
+ import { contentPath, ContentPathError, MEDIA_DIR, slugify } from "@micacms/core";
10
+ import type { ContentFiles } from "../workspace/files.js";
11
+ import { imageSize } from "image-size";
12
+
13
+ import { ApiError, badRequest } from "../lib/errors.js";
14
+ import { detectFileType, isAccepted, isImageType, SVG_TYPE } from "../lib/file-type.js";
15
+
16
+ /** Where uploads go when they are not attached to a particular entry. */
17
+ export const SHARED_MEDIA_DIR = "media";
18
+
19
+ export interface MediaServiceDeps {
20
+ files: ContentFiles;
21
+ maxBytes: number;
22
+ }
23
+
24
+ export interface MediaFile {
25
+ /** Path relative to the content root: the value a `media()` field holds. */
26
+ path: string;
27
+ filename: string;
28
+ mimeType: string;
29
+ size: number;
30
+ width: number | null;
31
+ height: number | null;
32
+ }
33
+
34
+ export function createMediaService(deps: MediaServiceDeps) {
35
+ const { files, maxBytes } = deps;
36
+
37
+ /** A name that is unique in `directory`. Only there can a collision overwrite a file. */
38
+ const uniqueName = async (directory: string, filename: string): Promise<string> => {
39
+ const taken = new Set(
40
+ (await files.list(directory)).filter((item) => item.type === "file").map((item) => item.name),
41
+ );
42
+
43
+ const extension = path.extname(filename);
44
+ const stem = filename.slice(0, filename.length - extension.length);
45
+
46
+ let candidate = filename;
47
+ for (let n = 2; taken.has(candidate); n += 1) candidate = `${stem}-${n}${extension}`;
48
+ return candidate;
49
+ };
50
+
51
+ return {
52
+ /** Reads and checks an upload, without writing it. */
53
+ async prepare(input: {
54
+ file: File;
55
+ /** Directory to place the file in, relative to the content root. */
56
+ directory?: string;
57
+ accept?: "image" | "document" | "any";
58
+ }): Promise<{ file: MediaFile; contents: Uint8Array }> {
59
+ const { file } = input;
60
+ const directory = uploadDirectory(input.directory ?? SHARED_MEDIA_DIR);
61
+
62
+ if (file.size === 0) throw badRequest("The file is empty.");
63
+ if (file.size > maxBytes) {
64
+ throw new ApiError(
65
+ "payload_too_large",
66
+ `The file is ${formatBytes(file.size)}. The limit is ${formatBytes(maxBytes)}.`,
67
+ );
68
+ }
69
+
70
+ const contents = new Uint8Array(await file.arrayBuffer());
71
+ // From the bytes, never from the browser's claim.
72
+ const detected = detectFileType(contents);
73
+
74
+ if (!detected) {
75
+ throw new ApiError("unsupported_media_type", "This file type is not supported.");
76
+ }
77
+ if (detected.mimeType === SVG_TYPE) throw refuseSvg();
78
+ if (!isAccepted(detected.mimeType, input.accept ?? "any")) {
79
+ throw new ApiError(
80
+ "unsupported_media_type",
81
+ `A ${input.accept} was expected, but this is a ${detected.mimeType}.`,
82
+ );
83
+ }
84
+
85
+ const filename = await uniqueName(directory, safeName(file.name, detected.extension));
86
+
87
+ return {
88
+ contents,
89
+ file: {
90
+ path: `${directory}/${filename}`,
91
+ filename,
92
+ mimeType: detected.mimeType,
93
+ size: contents.byteLength,
94
+ ...dimensions(contents, detected.mimeType),
95
+ },
96
+ };
97
+ },
98
+
99
+ /**
100
+ * Every media file in the repository. A tree walk is sufficient because
101
+ * content trees are small.
102
+ */
103
+ async list(): Promise<MediaFile[]> {
104
+ const found: MediaFile[] = [];
105
+
106
+ const walk = async (directory: string): Promise<void> => {
107
+ for (const item of await files.list(directory)) {
108
+ const child = directory === "" ? item.name : `${directory}/${item.name}`;
109
+
110
+ if (item.type === "directory") {
111
+ await walk(child);
112
+ continue;
113
+ }
114
+
115
+ if (!isMediaName(item.name)) continue;
116
+
117
+ const bytes = await files.readBinary(child);
118
+ if (!bytes) continue;
119
+
120
+ const mimeType = typeOf(bytes, item.name);
121
+ if (mimeType === SVG_TYPE) continue;
122
+
123
+ found.push({
124
+ path: child,
125
+ filename: item.name,
126
+ mimeType,
127
+ size: bytes.byteLength,
128
+ ...dimensions(bytes, mimeType),
129
+ });
130
+ }
131
+ };
132
+
133
+ await walk("");
134
+ return found.sort((a, b) => a.path.localeCompare(b.path));
135
+ },
136
+
137
+ async read(mediaPath: string): Promise<{ bytes: Uint8Array; mimeType: string } | null> {
138
+ const bytes = await files.readBinary(contentPath(mediaPath));
139
+ if (!bytes) return null;
140
+
141
+ const mimeType = typeOf(bytes, mediaPath);
142
+ if (mimeType === SVG_TYPE) throw refuseSvg();
143
+ return { bytes, mimeType };
144
+ },
145
+ };
146
+ }
147
+
148
+ export type MediaService = ReturnType<typeof createMediaService>;
149
+
150
+ /** An upload goes into a `media/` folder inside the content root, and nowhere else. */
151
+ function uploadDirectory(directory: string): string {
152
+ let clean: string;
153
+ try {
154
+ clean = contentPath(directory);
155
+ } catch (error) {
156
+ if (error instanceof ContentPathError) throw badRequest(error.message);
157
+ throw error;
158
+ }
159
+ if (clean.split("/").at(-1) !== MEDIA_DIR) {
160
+ throw badRequest(`Uploads go into a "${MEDIA_DIR}" folder, not "${directory}".`);
161
+ }
162
+ return clean;
163
+ }
164
+
165
+ /** A slugified filename: safe as a path segment and as part of a URL. */
166
+ function safeName(original: string, extension: string): string {
167
+ const stem = slugify(path.basename(original).replace(/\.[^.]*$/, "")).slice(0, 80);
168
+ return `${stem || "upload"}.${extension}`;
169
+ }
170
+
171
+ /** Extension to media type, only for formats that byte-sniffing does not identify. */
172
+ const BY_EXTENSION: Record<string, string> = {
173
+ svg: "image/svg+xml",
174
+ pdf: "application/pdf",
175
+ txt: "text/plain",
176
+ csv: "text/csv",
177
+ doc: "application/msword",
178
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
179
+ xls: "application/vnd.ms-excel",
180
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
181
+ };
182
+
183
+ /**
184
+ * The bytes win over the extension, except that an SVG name always counts, so a
185
+ * leading comment cannot hide an SVG.
186
+ */
187
+ function typeOf(bytes: Uint8Array, name: string): string {
188
+ const byName = mimeFromName(name);
189
+ if (byName === SVG_TYPE) return SVG_TYPE;
190
+ return detectFileType(bytes)?.mimeType ?? byName;
191
+ }
192
+
193
+ const refuseSvg = () =>
194
+ new ApiError(
195
+ "unsupported_media_type",
196
+ "Mica does not accept SVG files, because they can contain scripts. Use PNG or WebP instead.",
197
+ );
198
+
199
+ function mimeFromName(name: string): string {
200
+ const extension = path.extname(name).slice(1).toLowerCase();
201
+ return BY_EXTENSION[extension] ?? "application/octet-stream";
202
+ }
203
+
204
+ /** Files a `media()` field could plausibly point at. */
205
+ function isMediaName(name: string): boolean {
206
+ return /\.(jpe?g|png|webp|avif|gif|tiff?|pdf|docx?|xlsx?|txt|csv)$/i.test(name);
207
+ }
208
+
209
+ function dimensions(
210
+ bytes: Uint8Array,
211
+ mimeType: string,
212
+ ): { width: number | null; height: number | null } {
213
+ if (bytes.byteLength === 0 || !isImageType(mimeType)) {
214
+ return { width: null, height: null };
215
+ }
216
+ try {
217
+ const size = imageSize(Buffer.from(bytes));
218
+ return { width: size.width ?? null, height: size.height ?? null };
219
+ } catch {
220
+ // A format image-size cannot read is still a valid upload.
221
+ return { width: null, height: null };
222
+ }
223
+ }
224
+
225
+ export function formatBytes(bytes: number): string {
226
+ if (bytes < 1024) return `${bytes} B`;
227
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} kB`;
228
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
229
+ }
@@ -0,0 +1,330 @@
1
+ /**
2
+ * The staging service on a Git working tree. Autosave has already written the
3
+ * files; Publish commits the reviewed paths.
4
+ *
5
+ * - Every Git operation is limited to an explicit path set, so other work in
6
+ * the same checkout is never swept in.
7
+ * - Only the reviewed files are published, as the editor saw them. Drafts of
8
+ * other entries stay drafts.
9
+ * - When the push fails, the commit stays as `committed-pending-push`; a
10
+ * retry pushes and never commits again.
11
+ */
12
+
13
+ import { MANIFEST_PATH, memorySource, type ContentSource, type PublishResult, type Site } from "@micacms/core";
14
+
15
+ import { ApiError, badRequest, notFound } from "../lib/errors.js";
16
+ import type { Logger } from "../lib/logger.js";
17
+ import { developerPolicy } from "../workspace/developer.js";
18
+ import type { ContentFiles } from "../workspace/files.js";
19
+ import { GitError } from "../workspace/git.js";
20
+ import { createWorkspaceLocks, type WorkspaceLocks } from "../workspace/locks.js";
21
+ import type { OwnerPolicy } from "../workspace/policy.js";
22
+ import {
23
+ aheadOfUpstream,
24
+ commitPaths,
25
+ currentBranch,
26
+ discardChange,
27
+ headRevision,
28
+ headSource,
29
+ overlaySource,
30
+ listTreeChanges,
31
+ locateTree,
32
+ type GitTree,
33
+ type TreeChange,
34
+ } from "../workspace/tree.js";
35
+ import {
36
+ analyseChanges,
37
+ compareSources,
38
+ describeProblems,
39
+ flattenProblems,
40
+ type AnnotatedChange,
41
+ type CompareTarget,
42
+ type Comparison,
43
+ type ContentStagingService,
44
+ type PublishInput,
45
+ type RepositoryInfo,
46
+ type StagingStatus,
47
+ type StagingValidation,
48
+ } from "./changes.js";
49
+
50
+ export interface WorkingTreeStagingDeps {
51
+ model: Site;
52
+ /**
53
+ * The working tree's files without the lock wrapper: this service holds the
54
+ * locks around whole operations.
55
+ */
56
+ files: ContentFiles;
57
+ contentRoot: string;
58
+ schemaRevision: string | null;
59
+ /** From `readOnlyParts`: a change to one of these is never published by this build. */
60
+ readOnly?: ReadonlyMap<string, string>;
61
+ /** `MICA_LOCAL_PUBLISH`, for the default developer policy. */
62
+ publishEnabled: boolean;
63
+ logger: Logger;
64
+ /** Defaults to the developer. */
65
+ policy?: OwnerPolicy;
66
+ /** Shared with the services that write content. */
67
+ locks?: WorkspaceLocks;
68
+ /**
69
+ * Set by a managed clone, whose Git runs in an isolated environment. Without
70
+ * it, the developer's Git finds the tree.
71
+ */
72
+ tree?: GitTree;
73
+ }
74
+
75
+ export function createWorkingTreeStagingService(
76
+ deps: WorkingTreeStagingDeps,
77
+ ): ContentStagingService {
78
+ const { model, files, contentRoot, logger } = deps;
79
+ const policy = deps.policy ?? developerPolicy({ publishEnabled: deps.publishEnabled, logger });
80
+ const locks = deps.locks ?? createWorkspaceLocks();
81
+
82
+ /** `source` is what the result holds: the whole working tree, or HEAD with a selection. */
83
+ const analyse = async (tree: GitTree, treeChanges: TreeChange[], source: ContentSource = files) => {
84
+ const { changes, validation } = await analyseChanges(
85
+ model,
86
+ source,
87
+ treeChanges,
88
+ headSource(tree),
89
+ );
90
+
91
+ // The field validator cannot see a merge conflict: the file holds Git's conflict format.
92
+ for (const change of treeChanges) {
93
+ if (!change.conflicted) continue;
94
+ validation.problems.push({
95
+ path: change.path,
96
+ label: change.path,
97
+ issues: [
98
+ { path: "", message: "This file has a Git merge conflict. Resolve it with your Git tools." },
99
+ ],
100
+ });
101
+ }
102
+ // By the first path segment, so that a stray file in a read-only collection is caught too.
103
+ for (const change of changes) {
104
+ const head = change.path.split("/")[0] ?? "";
105
+ const reason = deps.readOnly?.get(`collections.${head}`) ?? deps.readOnly?.get(`singletons.${head.replace(/\.json$/, "")}`);
106
+ if (reason) {
107
+ validation.problems.push({ path: change.path, label: change.owner.label, issues: [{ path: "", message: reason }] });
108
+ }
109
+ }
110
+ validation.ok = validation.problems.length === 0;
111
+
112
+ return { changes, validation };
113
+ };
114
+
115
+ const locate = async (): Promise<GitTree | { reason: string }> => deps.tree ?? locateTree(contentRoot);
116
+
117
+ const located = async (): Promise<GitTree> => {
118
+ const tree = await locate();
119
+ if ("reason" in tree) throw badRequest(tree.reason);
120
+ return tree;
121
+ };
122
+
123
+ const publishable = async (): Promise<GitTree> => {
124
+ const availability = policy.publishAvailability();
125
+ if (!availability.enabled) throw badRequest(availability.reason);
126
+ return located();
127
+ };
128
+
129
+ /** The key that keeps an entry's files together: a publication never splits one. */
130
+ const groupOf = (change: AnnotatedChange): string => {
131
+ const owner = change.owner;
132
+ if (owner.kind === "entry") return `entry:${owner.collection}/${owner.slug}`;
133
+ if (owner.kind === "singleton") return `singleton:${owner.name}`;
134
+ if (owner.kind === "order") return `order:${owner.collection}`;
135
+ return `file:${change.path}`;
136
+ };
137
+
138
+ const withRevisions = async (changes: AnnotatedChange[]): Promise<AnnotatedChange[]> =>
139
+ Promise.all(
140
+ changes.map(async (change) => ({
141
+ ...change,
142
+ revision: change.kind === "delete" ? null : await files.revision(change.path),
143
+ })),
144
+ );
145
+
146
+ /** Refused for the developer's checkout: Mica never fetches or merges there. */
147
+ const managedOnly = <T>(action: T | undefined): T => {
148
+ if (action === undefined) throw notFound("This is the developer's checkout; Git there is theirs.");
149
+ return action;
150
+ };
151
+
152
+ return {
153
+ async status(): Promise<StagingStatus> {
154
+ const tree = await locate();
155
+ const sync = policy.syncStatus?.() ?? null;
156
+
157
+ if ("reason" in tree) {
158
+ return {
159
+ owner: policy.owner,
160
+ publish: { enabled: false, reason: tree.reason },
161
+ head: null,
162
+ branch: null,
163
+ ahead: null,
164
+ changes: [],
165
+ validation: emptyValidation(),
166
+ sync,
167
+ };
168
+ }
169
+
170
+ const { changes, validation, head, branch } = await locks.tree.read(async () => {
171
+ const analysed = await analyse(tree, await listTreeChanges(tree));
172
+ return {
173
+ ...analysed,
174
+ changes: await withRevisions(analysed.changes),
175
+ head: await headRevision(tree),
176
+ branch: await currentBranch(tree),
177
+ };
178
+ });
179
+
180
+ return {
181
+ owner: policy.owner,
182
+ publish: policy.publishAvailability(),
183
+ head,
184
+ branch,
185
+ ahead: await aheadOfUpstream(tree),
186
+ changes,
187
+ validation,
188
+ sync,
189
+ };
190
+ },
191
+
192
+ async publish(input: PublishInput): Promise<PublishResult> {
193
+ const message = input.message.trim();
194
+ if (!message) throw badRequest("A publication needs a commit message.");
195
+ const reviewed = Object.keys(input.paths);
196
+ if (reviewed.length === 0) throw badRequest("Choose at least one change to publish.");
197
+
198
+ const tree = await publishable();
199
+
200
+ // One publication at a time from sync to push: no sync can run in
201
+ // between, and no second commit can stack up.
202
+ return locks.remote.run(async () => {
203
+ await policy.prepare(tree);
204
+
205
+ const revision = await locks.tree.write(async () => {
206
+ const pending = await withRevisions((await analyse(tree, await listTreeChanges(tree))).changes);
207
+ const byPath = new Map(pending.map((change) => [change.path, change]));
208
+
209
+ // The review gate refuses a file saved again since the review, a
210
+ // reviewed file that is no longer pending, or another file of a
211
+ // reviewed entry. Drafts of other entries never block a publication.
212
+ const reviewedGroups = new Set(
213
+ reviewed.map((path) => byPath.get(path)).flatMap((change) => (change ? [groupOf(change)] : [])),
214
+ );
215
+ const stale = [
216
+ ...reviewed.filter((path) => byPath.get(path)?.revision !== input.paths[path] || !byPath.has(path)),
217
+ ...pending
218
+ .filter((change) => reviewedGroups.has(groupOf(change)) && !(change.path in input.paths))
219
+ .map((change) => change.path),
220
+ ];
221
+ if (stale.length > 0) {
222
+ throw new ApiError(
223
+ "conflict",
224
+ "Some of these changes were edited after you reviewed them. Review them again, then publish.",
225
+ );
226
+ }
227
+
228
+ // The model drift gate: validation used the model that this process started with.
229
+ if ((await files.revision(MANIFEST_PATH)) !== deps.schemaRevision) {
230
+ throw new ApiError(
231
+ "conflict",
232
+ "The content model changed after this server started. Restart Mica to load " +
233
+ "the new model, then review the pending changes and publish again.",
234
+ );
235
+ }
236
+
237
+ const selected = (await listTreeChanges(tree)).filter((change) => change.path in input.paths);
238
+ // Checked against what the commit will hold: drafts left behind are not on the site.
239
+ const result = overlaySource(headSource(tree), files, selected.map((change) => change.path));
240
+ const { validation } = await analyse(tree, selected, result);
241
+ if (!validation.ok) {
242
+ throw new ApiError("validation_failed", describeProblems(validation.problems), {
243
+ issues: flattenProblems(validation.problems),
244
+ });
245
+ }
246
+
247
+ try {
248
+ const revision = await commitPaths(tree, reviewed, message, policy.commitAuthor(input.author));
249
+ logger.info("Published", { revision, files: reviewed.length, owner: policy.owner });
250
+ return revision;
251
+ } catch (error) {
252
+ if (error instanceof GitError) {
253
+ // Git's message is the actionable part: no identity, a hook that
254
+ // refused, a rebase in progress.
255
+ throw new ApiError("conflict", `Git refused the commit: ${error.message}`);
256
+ }
257
+ throw error;
258
+ }
259
+ });
260
+
261
+ return policy.push(tree, revision);
262
+ });
263
+ },
264
+
265
+ async push(): Promise<PublishResult> {
266
+ const tree = await publishable();
267
+
268
+ return locks.remote.run(async () => {
269
+ const revision = await headRevision(tree);
270
+ if (revision === null) throw badRequest("There is no commit to push yet.");
271
+ return policy.push(tree, revision);
272
+ });
273
+ },
274
+
275
+ async discard(requested: string[]): Promise<{ discarded: string[] }> {
276
+ const tree = await located();
277
+
278
+ return locks.tree.write(async () => {
279
+ // Discard is destructive, so it reaches only paths that the change list names.
280
+ const current = new Map((await listTreeChanges(tree)).map((change) => [change.path, change]));
281
+
282
+ const discarded: string[] = [];
283
+ for (const path of requested) {
284
+ const change = current.get(path);
285
+ if (!change) continue;
286
+ await discardChange(tree, change);
287
+ discarded.push(path);
288
+ }
289
+
290
+ return { discarded };
291
+ });
292
+ },
293
+
294
+ async compare(target: CompareTarget, against: "head" | "origin" = "head"): Promise<Comparison> {
295
+ const tree = await locate();
296
+ if (against === "origin" && !policy.upstreamRef) throw badRequest("There is no website version to compare with here.");
297
+ // Without a repository nothing is published, so "before" is an empty source.
298
+ const published =
299
+ "reason" in tree
300
+ ? memorySource({}, "unpublished")
301
+ : headSource(tree, against === "origin" ? policy.upstreamRef : "HEAD");
302
+ return locks.tree.read(() => compareSources(model, published, files, target));
303
+ },
304
+
305
+ async sync(): Promise<void> {
306
+ await managedOnly(policy.sync)();
307
+ },
308
+
309
+ async resolve(group: string, choice: "mine" | "website"): Promise<void> {
310
+ await managedOnly(policy.resolve)(group, choice);
311
+ },
312
+
313
+ async repository(): Promise<RepositoryInfo> {
314
+ const tree = await locate();
315
+ const described = (await policy.describe?.()) ?? null;
316
+ return {
317
+ owner: policy.owner,
318
+ branch: "reason" in tree ? null : await currentBranch(tree),
319
+ head: "reason" in tree ? null : await headRevision(tree),
320
+ url: described?.url ?? null,
321
+ cloneBytes: described?.cloneBytes ?? null,
322
+ sync: policy.syncStatus?.() ?? null,
323
+ };
324
+ },
325
+ };
326
+ }
327
+
328
+ function emptyValidation(): StagingValidation {
329
+ return { ok: true, problems: [] };
330
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The policy for the developer's own checkout. Mica never fetches, merges or
3
+ * resets here. Publish pushes with the developer's `git`, credentials and
4
+ * upstream; a failed push keeps the commit.
5
+ */
6
+
7
+ import type { PublishResult } from "@micacms/core";
8
+
9
+ import type { Logger } from "../lib/logger.js";
10
+ import { GitError } from "./git.js";
11
+ import type { OwnerPolicy, PublishAvailability } from "./policy.js";
12
+ import type { GitTree } from "./tree.js";
13
+
14
+ export interface DeveloperPolicyOptions {
15
+ /** `MICA_LOCAL_PUBLISH`. */
16
+ publishEnabled: boolean;
17
+ logger: Logger;
18
+ }
19
+
20
+ export function developerPolicy(options: DeveloperPolicyOptions): OwnerPolicy {
21
+ return {
22
+ owner: "developer",
23
+
24
+ publishAvailability(): PublishAvailability {
25
+ return options.publishEnabled
26
+ ? { enabled: true }
27
+ : { enabled: false, reason: "Publishing from local mode is disabled (MICA_LOCAL_PUBLISH=false)." };
28
+ },
29
+
30
+ async prepare() {},
31
+
32
+ // The commit keeps the developer's own Git identity.
33
+ commitAuthor: () => undefined,
34
+
35
+ async push(tree: GitTree, revision: string): Promise<PublishResult> {
36
+ try {
37
+ await tree.git.run(["push"]);
38
+ return { state: "published", revision };
39
+ } catch (error) {
40
+ const pushError = error instanceof GitError ? error.message : String(error);
41
+ options.logger.warn("Publish committed but the push failed", { revision, pushError });
42
+ return { state: "committed-pending-push", revision, pushError };
43
+ }
44
+ },
45
+ };
46
+ }