@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,314 @@
1
+ /**
2
+ * Git operations on a working tree whose content root is the draft workspace.
3
+ * The owner policy does the push. Every operation is limited to paths under the
4
+ * content root, never `git add -A` over the repository, so other work in the
5
+ * same checkout is never swept into a commit.
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { realpath, rm } from "node:fs/promises";
10
+ import { join, relative, sep } from "node:path";
11
+
12
+ import type { ContentChange, ContentSource, DirectoryEntry } from "@micacms/core";
13
+ import { within } from "@micacms/core/node";
14
+
15
+ import type { Author } from "./files.js";
16
+ import { GitError, gitRunner, type GitRunner, type GitRunnerOptions } from "./git.js";
17
+
18
+ /**
19
+ * Makes every path argument literal: `tour[1].json` is not a pattern that also
20
+ * matches `tour1.json`.
21
+ */
22
+ export const LITERAL = "--literal-pathspecs";
23
+
24
+ /** A content root inside a Git repository. */
25
+ export interface GitTree {
26
+ /** Runs in the repository root. */
27
+ git: GitRunner;
28
+ /** Content root relative to the repository root; "" when they coincide. */
29
+ prefix: string;
30
+ /** Absolute path of the content root. */
31
+ contentRoot: string;
32
+ }
33
+
34
+ /** A changed file plus what only Git status knows about it. */
35
+ export interface TreeChange extends ContentChange {
36
+ /** Not yet known to Git — discarding means deleting, not restoring. */
37
+ untracked: boolean;
38
+ /** Mid-merge conflict markers. Unpublishable until resolved with Git tools. */
39
+ conflicted: boolean;
40
+ }
41
+
42
+ /**
43
+ * Finds the repository the content root sits in, freshly each time: a
44
+ * `git init` after startup should start working, not require a restart.
45
+ */
46
+ export async function locateTree(
47
+ contentRoot: string,
48
+ options: GitRunnerOptions = {},
49
+ ): Promise<GitTree | { reason: string }> {
50
+ const probe = gitRunner(contentRoot, options);
51
+ try {
52
+ const repoRoot = await probe.run(["rev-parse", "--show-toplevel"]);
53
+ // Git resolves symlinks in the toplevel (`/private/var` for `/var` on
54
+ // macOS); do the same, or the prefix is wrong.
55
+ const resolved = await realpath(contentRoot);
56
+ return {
57
+ git: gitRunner(repoRoot, options),
58
+ prefix: toPosix(relative(repoRoot, resolved)),
59
+ contentRoot,
60
+ };
61
+ } catch (error) {
62
+ return {
63
+ reason:
64
+ error instanceof GitError && error.exitCode === null
65
+ ? error.message
66
+ : "The content directory is not inside a Git repository.",
67
+ };
68
+ }
69
+ }
70
+
71
+ /** The content root's changes, as `git status` sees them. */
72
+ export async function listTreeChanges(tree: GitTree): Promise<TreeChange[]> {
73
+ // `-uall`: Git otherwise collapses a new, untracked entry directory to one line.
74
+ // `GIT_OPTIONAL_LOCKS=0`: no `index.lock`, so a parallel commit cannot fail on it.
75
+ const raw = await tree.git.run(
76
+ [LITERAL, "status", "--porcelain=v1", "-z", "-uall", "--", tree.prefix === "" ? "." : tree.prefix],
77
+ { env: { GIT_OPTIONAL_LOCKS: "0" } },
78
+ );
79
+ return parseStatus(raw, tree.prefix);
80
+ }
81
+
82
+ /**
83
+ * Stages and commits only `paths` (content-root relative) and returns the new
84
+ * HEAD. The pathspec limits both steps, so all other staged or unstaged work
85
+ * stays. Without `author`, Git's configured identity applies.
86
+ */
87
+ export async function commitPaths(
88
+ tree: GitTree,
89
+ paths: string[],
90
+ message: string,
91
+ author?: Author,
92
+ ): Promise<string> {
93
+ const inRepository = await stagePaths(tree, paths);
94
+ const as = author ? [`--author=${author.name} <${author.email}>`] : [];
95
+ await tree.git.run([LITERAL, "commit", "-m", message, ...as, "--", ...inRepository]);
96
+ return tree.git.run(["rev-parse", "HEAD"]);
97
+ }
98
+
99
+ /**
100
+ * Commits `paths` again with the message, author and date of `original`, after
101
+ * a rejected push. Returns the new HEAD.
102
+ */
103
+ export async function recommitPaths(tree: GitTree, paths: string[], original: string): Promise<string> {
104
+ const inRepository = await stagePaths(tree, paths);
105
+ await tree.git.run([LITERAL, "commit", "-C", original, "--", ...inRepository]);
106
+ return tree.git.run(["rev-parse", "HEAD"]);
107
+ }
108
+
109
+ /**
110
+ * Stages `paths`, because `git commit -- <paths>` refuses a file Git has never
111
+ * seen. A path gone from the disk uses `rm --cached`, because `git add` refuses
112
+ * a deletion that is already staged (as `reset --soft` leaves it).
113
+ */
114
+ async function stagePaths(tree: GitTree, paths: string[]): Promise<string[]> {
115
+ const present: string[] = [];
116
+ const gone: string[] = [];
117
+ for (const path of paths) {
118
+ (existsSync(within(tree.contentRoot, path)) ? present : gone).push(inRepo(tree.prefix, path));
119
+ }
120
+ for (const batch of chunked(present, 500)) {
121
+ await tree.git.run([LITERAL, "add", "-A", "--", ...batch]);
122
+ }
123
+ for (const batch of chunked(gone, 500)) {
124
+ await tree.git.run([LITERAL, "rm", "--cached", "--ignore-unmatch", "--quiet", "--", ...batch]);
125
+ }
126
+ return paths.map((path) => inRepo(tree.prefix, path));
127
+ }
128
+
129
+ /** Puts one changed file back to its HEAD version, or removes it if HEAD has none. */
130
+ export async function discardChange(tree: GitTree, change: TreeChange): Promise<void> {
131
+ if (change.kind === "create") {
132
+ // Unstage a staged creation first, so the index does not point at a deleted file.
133
+ if (!change.untracked) {
134
+ await tree.git.tryRun([LITERAL, "rm", "--cached", "--force", "--", inRepo(tree.prefix, change.path)]);
135
+ }
136
+ await rm(within(tree.contentRoot, change.path), { force: true });
137
+ return;
138
+ }
139
+ await tree.git.run([
140
+ LITERAL,
141
+ "restore",
142
+ "--source=HEAD",
143
+ "--staged",
144
+ "--worktree",
145
+ "--",
146
+ inRepo(tree.prefix, change.path),
147
+ ]);
148
+ }
149
+
150
+ /** HEAD's commit id, or null before the first commit. */
151
+ export function headRevision(tree: GitTree): Promise<string | null> {
152
+ return tree.git.tryRun(["rev-parse", "HEAD"]);
153
+ }
154
+
155
+ /** The checked-out branch, or null when there is none to name. */
156
+ export function currentBranch(tree: GitTree): Promise<string | null> {
157
+ return tree.git.tryRun(["rev-parse", "--abbrev-ref", "HEAD"]);
158
+ }
159
+
160
+ /** Commits waiting to be pushed, or null when there is no upstream to say. */
161
+ export async function aheadOfUpstream(tree: GitTree): Promise<number | null> {
162
+ const counted = await tree.git.tryRun(["rev-list", "--count", "@{upstream}..HEAD"]);
163
+ if (counted === null) return null;
164
+ const ahead = Number(counted);
165
+ return Number.isFinite(ahead) ? ahead : null;
166
+ }
167
+
168
+ /**
169
+ * The content root as `ref` has it, read from Git's object store, because the
170
+ * working tree holds the drafts.
171
+ */
172
+ export function headSource(tree: GitTree, ref = "HEAD"): ContentSource {
173
+ const at = (path: string) => `${ref}:${path === "" ? tree.prefix : inRepo(tree.prefix, path)}`;
174
+
175
+ return {
176
+ origin: ref,
177
+
178
+ async read(path) {
179
+ // "Not in HEAD" and "no commits yet" both mean that there is no published version.
180
+ return tree.git.tryRun(["show", at(path)]);
181
+ },
182
+
183
+ async list(path) {
184
+ const raw = await tree.git.tryRun(["ls-tree", "-z", at(path)]);
185
+ if (raw === null) return [];
186
+
187
+ const entries: DirectoryEntry[] = [];
188
+ for (const field of raw.split("\0")) {
189
+ const tab = field.indexOf("\t");
190
+ if (tab === -1) continue;
191
+ const type = field.slice(0, tab).split(" ")[1];
192
+ entries.push({ name: field.slice(tab + 1), type: type === "tree" ? "directory" : "file" });
193
+ }
194
+ return entries;
195
+ },
196
+ };
197
+ }
198
+
199
+ /**
200
+ * `base` with `paths` taken from `top`: what a commit of exactly those paths
201
+ * would hold. Publish checks a selection against it, not against drafts that
202
+ * stay behind.
203
+ */
204
+ export function overlaySource(base: ContentSource, top: ContentSource, paths: Iterable<string>): ContentSource {
205
+ const selected = new Set(paths);
206
+
207
+ return {
208
+ origin: `${base.origin} with the selected changes`,
209
+
210
+ read: (path) => (selected.has(path) ? top.read(path) : base.read(path)),
211
+
212
+ async list(directory) {
213
+ const prefix = directory === "" ? "" : `${directory}/`;
214
+ const items = new Map((await base.list(directory)).map((item) => [item.name, item]));
215
+ for (const path of selected) {
216
+ if (!path.startsWith(prefix)) continue;
217
+ const [name, ...deeper] = path.slice(prefix.length).split("/");
218
+ if (deeper.length > 0) items.set(name!, { name: name!, type: "directory" });
219
+ else if ((await top.read(path)) === null) items.delete(name!);
220
+ else items.set(name!, { name: name!, type: "file" });
221
+ }
222
+ return [...items.values()];
223
+ },
224
+ };
225
+ }
226
+
227
+ /**
228
+ * A save's temporary file (`*.mica-<pid>-<n>`), seen during a save or after a
229
+ * crash. Never content.
230
+ */
231
+ const TEMPORARY_FILE = /\.mica-\d+-\d+$/;
232
+
233
+ /**
234
+ * Parses `git status --porcelain=v1 -z` into content-root-relative changes.
235
+ * `-z` never changes a path; a rename is two fields and becomes a create and a
236
+ * delete. `.mica/` is excluded: the developer commits the manifest, and the
237
+ * drift check guards model changes.
238
+ */
239
+ export function parseStatus(raw: string, prefix: string): TreeChange[] {
240
+ const fields = raw.split("\0").filter((field) => field !== "");
241
+ const changes: TreeChange[] = [];
242
+
243
+ const push = (
244
+ repoPath: string,
245
+ kind: TreeChange["kind"],
246
+ flags: { untracked?: boolean; conflicted?: boolean } = {},
247
+ ) => {
248
+ const path = relativeToContent(repoPath, prefix);
249
+ if (path === null || path === ".mica" || path.startsWith(".mica/")) return;
250
+ if (TEMPORARY_FILE.test(path)) return;
251
+ changes.push({
252
+ path,
253
+ kind,
254
+ untracked: flags.untracked ?? false,
255
+ conflicted: flags.conflicted ?? false,
256
+ });
257
+ };
258
+
259
+ for (let at = 0; at < fields.length; at += 1) {
260
+ const field = fields[at]!;
261
+ const x = field[0] ?? " ";
262
+ const y = field[1] ?? " ";
263
+ const repoPath = field.slice(3);
264
+
265
+ if (x === "?" && y === "?") {
266
+ push(repoPath, "create", { untracked: true });
267
+ continue;
268
+ }
269
+
270
+ // A merge conflict state, whatever the tree holds.
271
+ if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) {
272
+ push(repoPath, "update", { conflicted: true });
273
+ continue;
274
+ }
275
+
276
+ if (x === "R" || x === "C") {
277
+ // The next field is the source path; only a rename deletes it.
278
+ const from = fields[++at]!;
279
+ push(repoPath, "create");
280
+ if (x === "R") push(from, "delete");
281
+ continue;
282
+ }
283
+
284
+ if (x === "D" || y === "D") {
285
+ push(repoPath, "delete");
286
+ continue;
287
+ }
288
+
289
+ push(repoPath, x === "A" ? "create" : "update");
290
+ }
291
+
292
+ return changes;
293
+ }
294
+
295
+ function relativeToContent(repoPath: string, prefix: string): string | null {
296
+ if (prefix === "") return repoPath;
297
+ if (repoPath === prefix) return null;
298
+ return repoPath.startsWith(`${prefix}/`) ? repoPath.slice(prefix.length + 1) : null;
299
+ }
300
+
301
+ /** A content-root-relative path as Git sees it, from the repository root. */
302
+ export function inRepo(prefix: string, path: string): string {
303
+ return prefix === "" ? path : join(prefix, path).split(sep).join("/");
304
+ }
305
+
306
+ function toPosix(path: string): string {
307
+ return sep === "/" ? path : path.split(sep).join("/");
308
+ }
309
+
310
+ function chunked<T>(items: T[], size: number): T[][] {
311
+ const batches: T[][] = [];
312
+ for (let at = 0; at < items.length; at += size) batches.push(items.slice(at, at + size));
313
+ return batches;
314
+ }