@50arms/platform-mcp 0.0.0-stage → 0.1.437
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.
- package/README.md +114 -2
- package/dist/access-request.js +101 -0
- package/dist/agent-gate.js +69 -0
- package/dist/board-id.js +27 -0
- package/dist/bounded-retry.js +25 -0
- package/dist/checkout-docs-root.js +54 -0
- package/dist/clear-control.js +77 -0
- package/dist/compaction-control.js +142 -0
- package/dist/credential.js +104 -0
- package/dist/danxbot-api-examples.js +97 -0
- package/dist/dashboard-identity.js +14 -0
- package/dist/design-screenshot.js +138 -0
- package/dist/digest-hold.js +67 -0
- package/dist/docs-tools.js +140 -0
- package/dist/durable-write.js +56 -0
- package/dist/entrypoint.js +35 -0
- package/dist/forbidden-hint.js +23 -0
- package/dist/guarded-calls.js +21 -0
- package/dist/handlers.js +385 -0
- package/dist/heartbeat-reporter.js +156 -0
- package/dist/http-client.js +190 -0
- package/dist/index.js +1128 -0
- package/dist/json-body.js +12 -0
- package/dist/key-revoked-halt.js +30 -0
- package/dist/lifecycle.js +113 -0
- package/dist/listen.js +999 -0
- package/dist/listener-reminder.js +119 -0
- package/dist/machine-daemon/autostart.js +160 -0
- package/dist/machine-daemon/beat.js +77 -0
- package/dist/machine-daemon/cli.js +42 -0
- package/dist/machine-daemon/install.js +168 -0
- package/dist/machine-daemon/state.js +64 -0
- package/dist/machine-daemon/version.js +13 -0
- package/dist/machine-daemon.js +553 -0
- package/dist/machine-toolkit.js +460 -0
- package/dist/mini-app-build.js +159 -0
- package/dist/mini-app-checkout.js +215 -0
- package/dist/mini-app-output.js +111 -0
- package/dist/mini-app-publish.js +154 -0
- package/dist/mini-app-restore.js +175 -0
- package/dist/one-line.js +25 -0
- package/dist/pacing-relay.js +16 -0
- package/dist/permission-request.js +134 -0
- package/dist/permission-watch.js +145 -0
- package/dist/plan-events-relay.js +572 -0
- package/dist/plan-events-tool.js +9 -0
- package/dist/plugin-publish.js +38 -0
- package/dist/plugin-screenshot.js +81 -0
- package/dist/request-bound.js +21 -0
- package/dist/restart-notice.js +271 -0
- package/dist/result-limit.js +31 -0
- package/dist/role-instruction-file.js +36 -0
- package/dist/route-board-scope.generated.js +95 -0
- package/dist/route-board-scope.js +71 -0
- package/dist/session-access.js +322 -0
- package/dist/session-connection.js +264 -0
- package/dist/session-control-file.js +58 -0
- package/dist/session-key.js +125 -0
- package/dist/session-options.js +60 -0
- package/dist/session-stats.js +235 -0
- package/dist/session-stream.js +345 -0
- package/dist/session-title.js +129 -0
- package/dist/shutdown.js +38 -0
- package/dist/stop-fixes.js +50 -0
- package/dist/subagents-live.js +162 -0
- package/dist/terms-halt.js +39 -0
- package/dist/text-limit.js +24 -0
- package/dist/tool-access.js +114 -0
- package/dist/tool-defs.js +58 -0
- package/dist/tool-surface.js +59 -0
- package/dist/transcript-lineage.js +105 -0
- package/dist/types.js +45 -0
- package/dist/urgent-records.js +47 -0
- package/dist/version-floor.js +146 -0
- package/package.json +51 -4
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-5294 - the checkout side of `mini_app_publish`: which mini app target the dashboard declared, and whether THIS checkout (the one
|
|
3
|
+
* the design folder lives in) lets that target's build command run.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here reads a file for a SETTING. The targets arrive from the dashboard (`GET /api/repos/<repo name>`, `mini_app_targets`); this
|
|
6
|
+
* module only checks them against the filesystem and git, which are facts of a checkout, not a setting. It never reads `.danxbot/` or any
|
|
7
|
+
* `config.yml`.
|
|
8
|
+
*/
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
/** The two keys of the one canonical target shape (no version field, no repair of a row missing one). */
|
|
15
|
+
export const MINI_APP_TARGET_KEYS = ["name", "command"];
|
|
16
|
+
/**
|
|
17
|
+
* The targets out of `GET /api/repos/<repo name>`'s body. A body without the array, or a row missing a field or holding a mistyped one, is a
|
|
18
|
+
* fault in what the dashboard answered: it fails loud naming the index and field, and the row is never repaired.
|
|
19
|
+
*/
|
|
20
|
+
export function parseMiniAppTargets(body, repo) {
|
|
21
|
+
const targets = body?.mini_app_targets;
|
|
22
|
+
if (!Array.isArray(targets)) {
|
|
23
|
+
throw new Error(`mini_app_publish: GET /api/repos/${repo} answered without a mini_app_targets array (a dashboard older than this tool, or a repo it does not serve?)`);
|
|
24
|
+
}
|
|
25
|
+
return targets.map((row, i) => {
|
|
26
|
+
const where = `mini_app_targets[${i}] of repo "${repo}"`;
|
|
27
|
+
if (typeof row !== "object" || row === null || Array.isArray(row))
|
|
28
|
+
throw new Error(`mini_app_publish: ${where} is not an object`);
|
|
29
|
+
const fields = row;
|
|
30
|
+
const extra = Object.keys(fields).filter((key) => !MINI_APP_TARGET_KEYS.includes(key));
|
|
31
|
+
if (extra.length > 0)
|
|
32
|
+
throw new Error(`mini_app_publish: ${where} has unknown field(s) ${extra.map((k) => `"${k}"`).join(", ")}`);
|
|
33
|
+
if (typeof fields.name !== "string" || fields.name === "") {
|
|
34
|
+
throw new Error(`mini_app_publish: ${where} has no "name" (a non-empty string is required)`);
|
|
35
|
+
}
|
|
36
|
+
const command = fields.command;
|
|
37
|
+
if (!Array.isArray(command) || command.length < 2 || command.some((part) => typeof part !== "string" || part === "")) {
|
|
38
|
+
throw new Error(`mini_app_publish: ${where} has no "command" (an array of at least two non-empty strings is required: the program, then the committed script)`);
|
|
39
|
+
}
|
|
40
|
+
return { name: fields.name, command: command };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** The declared target named `name`, or the refusal an agent can act on: no targets at all, or the declared names. */
|
|
44
|
+
export function pickMiniAppTarget(targets, name, repo) {
|
|
45
|
+
if (targets.length === 0) {
|
|
46
|
+
throw new Error(`mini_app_publish: repo ${repo} declares no mini app targets; add one in Settings > Board > General (Mini app targets)`);
|
|
47
|
+
}
|
|
48
|
+
const found = targets.find((t) => t.name === name);
|
|
49
|
+
if (found === undefined) {
|
|
50
|
+
throw new Error(`mini_app_publish: repo ${repo} declares no mini app target "${name}"; declared: ${targets.map((t) => `"${t.name}"`).join(", ")}`);
|
|
51
|
+
}
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
54
|
+
/** True when `child` is strictly inside `parent` (both absolute, already real paths). */
|
|
55
|
+
export function isStrictlyInside(parent, child) {
|
|
56
|
+
const rel = path.relative(parent, child);
|
|
57
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
58
|
+
}
|
|
59
|
+
/** `p` relative to `base`, always with forward slashes (the form the dashboard stores repo-relative paths in). */
|
|
60
|
+
export function relativePosix(base, p) {
|
|
61
|
+
return path.relative(base, p).split(path.sep).join("/");
|
|
62
|
+
}
|
|
63
|
+
/** Run `git -C <repoDir> <args>` (no shell); a non-zero exit is data (`ls-files --error-unmatch`, `diff --quiet` and `check-ignore -q` answer through it). */
|
|
64
|
+
async function git(repoDir, args) {
|
|
65
|
+
try {
|
|
66
|
+
const { stdout, stderr } = await execFileAsync("git", ["-C", repoDir, ...args], { windowsHide: true });
|
|
67
|
+
return { code: 0, stdout, stderr };
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
const failure = err;
|
|
71
|
+
if (typeof failure.code === "number")
|
|
72
|
+
return { code: failure.code, stdout: failure.stdout ?? "", stderr: failure.stderr ?? "" };
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** git's trimmed stdout; any non-zero exit throws with git's own message. */
|
|
77
|
+
async function gitText(repoDir, args) {
|
|
78
|
+
const run = await git(repoDir, args);
|
|
79
|
+
if (run.code !== 0)
|
|
80
|
+
throw new Error(`git ${args.join(" ")} failed (exit ${run.code}): ${run.stderr.trim()}`);
|
|
81
|
+
return run.stdout.trim();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The checkout the design directory lives in: `git -C <dir> rev-parse --show-toplevel` (no shell), a fact of the checkout and not a
|
|
85
|
+
* setting. It resolves a subdirectory, and a `.claude/worktrees/<n>` checkout, to that checkout's own top level.
|
|
86
|
+
*/
|
|
87
|
+
export async function resolveRepoRoot(dir) {
|
|
88
|
+
let top;
|
|
89
|
+
try {
|
|
90
|
+
top = await gitText(dir, ["rev-parse", "--show-toplevel"]);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
throw new Error(`mini_app_publish: dir "${dir}" is not inside a git checkout: ${err.message}`);
|
|
94
|
+
}
|
|
95
|
+
return realpath(top);
|
|
96
|
+
}
|
|
97
|
+
/** `git -C <repoRoot> rev-parse HEAD`: the commit the source files are taken from. */
|
|
98
|
+
export async function readHeadCommit(repoRoot) {
|
|
99
|
+
return gitText(repoRoot, ["rev-parse", "HEAD"]);
|
|
100
|
+
}
|
|
101
|
+
/** The absolute, real path of `dir` (resolved against `cwd` when relative); it must exist and be a directory. */
|
|
102
|
+
export async function resolveDesignDir(dir, cwd) {
|
|
103
|
+
const abs = path.resolve(cwd, dir);
|
|
104
|
+
let real;
|
|
105
|
+
try {
|
|
106
|
+
real = await realpath(abs);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new Error(`mini_app_publish: dir "${dir}" does not exist (resolved to ${abs})`);
|
|
110
|
+
}
|
|
111
|
+
if (!(await stat(real)).isDirectory())
|
|
112
|
+
throw new Error(`mini_app_publish: dir "${dir}" is not a directory`);
|
|
113
|
+
return real;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* DX-5294: restated from `isUnsafeRepoRelativePath` in src/issues/repo-path-safety.ts (this package cannot import the backend): an
|
|
117
|
+
* absolute path, a Windows drive-letter path or a `..` segment.
|
|
118
|
+
*/
|
|
119
|
+
function isUnsafeRepoRelativePath(value) {
|
|
120
|
+
if (value.startsWith("/") || value.startsWith("\\"))
|
|
121
|
+
return true;
|
|
122
|
+
if (/^[A-Za-z]:[\\/]/.test(value))
|
|
123
|
+
return true; // Windows drive-letter absolute
|
|
124
|
+
return value.split(/[\\/]/).includes("..");
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* DX-5294: a repo-relative path a target may name (the build script): as the server's `isUnsafePath` in
|
|
128
|
+
* src/issues/mini-app-targets.ts, which also refuses any backslash. The parity test in src/dispatch/design-publish-restated-constants.test.ts pins the two.
|
|
129
|
+
*/
|
|
130
|
+
export const isUnsafeTargetPath = (value) => value.includes("\\") || isUnsafeRepoRelativePath(value);
|
|
131
|
+
/** DX-5294: the program must be a bare name (no path separator, no whitespace), as the server's rule; pinned by the same parity test. */
|
|
132
|
+
export const isInvalidProgram = (program) => /[\\/\s]/.test(program);
|
|
133
|
+
/** The Windows programs that are `.cmd` shims: a spawn with no shell cannot start one. */
|
|
134
|
+
const WINDOWS_SHIM_PROGRAMS = new Set(["npm", "npx", "yarn", "pnpm"]);
|
|
135
|
+
/** The design folder must hold no file tracked by git and must be ignored by it (design folders are gitignored working files). */
|
|
136
|
+
async function requireUntrackedAndIgnored(repoRoot, rel) {
|
|
137
|
+
const tracked = await git(repoRoot, ["ls-files", "--", rel]);
|
|
138
|
+
if (tracked.code !== 0)
|
|
139
|
+
throw new Error(`mini_app_publish: git ls-files failed (exit ${tracked.code}) for dir "${rel}": ${tracked.stderr.trim()}`);
|
|
140
|
+
if (tracked.stdout.trim() !== "") {
|
|
141
|
+
throw new Error(`mini_app_publish: dir "${rel}" holds files tracked by git; a design folder is a gitignored working folder: untrack it or use another folder`);
|
|
142
|
+
}
|
|
143
|
+
const ignored = await git(repoRoot, ["check-ignore", "-q", "--", rel]);
|
|
144
|
+
if (ignored.code === 1) {
|
|
145
|
+
throw new Error(`mini_app_publish: dir "${rel}" is not ignored by git; a design folder is a gitignored working folder: add it to .gitignore`);
|
|
146
|
+
}
|
|
147
|
+
if (ignored.code !== 0)
|
|
148
|
+
throw new Error(`mini_app_publish: git check-ignore failed (exit ${ignored.code}) for dir "${rel}": ${ignored.stderr.trim()}`);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Check a target against the checkout BEFORE anything is built or posted. Every refusal names the target and the field:
|
|
152
|
+
* 1. `command[0]` is a bare program name and `command[1]` a safe repo-relative path;
|
|
153
|
+
* 2. on Windows the program is not a `.cmd`/`.bat` shim (a spawn without a shell cannot start one);
|
|
154
|
+
* 3. the design folder is inside the repo root, not tracked by git, and ignored by it (design folders are gitignored working files);
|
|
155
|
+
* 4. `command[1]`, the build script, is tracked and unchanged from HEAD.
|
|
156
|
+
*/
|
|
157
|
+
export async function checkTarget(target, repo, repoRoot, dirAbs, platform) {
|
|
158
|
+
const refuse = (field, value, why) => new Error(`mini_app_publish: target "${target.name}" of repo ${repo}: ${field} "${value}" ${why} (checkout ${repoRoot})`);
|
|
159
|
+
const [program, script] = target.command;
|
|
160
|
+
if (isInvalidProgram(program))
|
|
161
|
+
throw refuse("command[0]", program, "must be a bare program name, with no path separator or whitespace");
|
|
162
|
+
const lower = program.toLowerCase();
|
|
163
|
+
if (platform === "win32" && (WINDOWS_SHIM_PROGRAMS.has(lower) || /\.(cmd|bat)$/.test(lower))) {
|
|
164
|
+
throw refuse("command[0]", program, "is a .cmd shim, which cannot be started without a shell: name `node` and a committed script instead, e.g. [\"node\", \"scripts/build-mini-app.mjs\"]");
|
|
165
|
+
}
|
|
166
|
+
if (isUnsafeTargetPath(script))
|
|
167
|
+
throw refuse("command[1]", script, "must be a repo-relative path with no `..` segment, backslash, absolute path or drive letter");
|
|
168
|
+
const designDirRel = relativePosix(repoRoot, dirAbs);
|
|
169
|
+
if (!isStrictlyInside(repoRoot, dirAbs))
|
|
170
|
+
throw new Error(`mini_app_publish: dir "${dirAbs}" is not inside the repo root ${repoRoot}`);
|
|
171
|
+
await requireUntrackedAndIgnored(repoRoot, designDirRel);
|
|
172
|
+
// DX-5294 known limit: this proves the NAMED script is committed and unchanged, not what that script imports.
|
|
173
|
+
const known = await git(repoRoot, ["ls-files", "--error-unmatch", "--", script]);
|
|
174
|
+
if (known.code === 1)
|
|
175
|
+
throw refuse("command[1]", script, "is not tracked by git: commit the build script before publishing");
|
|
176
|
+
if (known.code !== 0)
|
|
177
|
+
throw new Error(`mini_app_publish: git ls-files failed (exit ${known.code}) for ${script}: ${known.stderr.trim()}`);
|
|
178
|
+
const changed = await git(repoRoot, ["diff", "--quiet", "HEAD", "--", script]);
|
|
179
|
+
if (changed.code === 1)
|
|
180
|
+
throw refuse("command[1]", script, "has changes against HEAD: commit the build script before publishing");
|
|
181
|
+
if (changed.code !== 0)
|
|
182
|
+
throw new Error(`mini_app_publish: git diff failed (exit ${changed.code}) for ${script}: ${changed.stderr.trim()}`);
|
|
183
|
+
return { repoRoot, dirAbs, designDirRel };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Every file under `dir` as `{path, content}`: the path repo-relative with forward slashes, the content strict UTF-8 (a file that is
|
|
187
|
+
* not refuses, naming it; a byte-order mark is kept, never stripped). The server's file-count and size caps answer a 413 for a
|
|
188
|
+
* directory that is too large; this reads what is there and does not guess at them.
|
|
189
|
+
*/
|
|
190
|
+
export async function readSourceFiles(dirAbs, repoRoot) {
|
|
191
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
192
|
+
const files = [];
|
|
193
|
+
const walk = async (current) => {
|
|
194
|
+
for (const entry of (await readdir(current, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
195
|
+
const full = path.join(current, entry.name);
|
|
196
|
+
if (entry.isDirectory()) {
|
|
197
|
+
await walk(full);
|
|
198
|
+
}
|
|
199
|
+
else if (entry.isFile()) {
|
|
200
|
+
const bytes = await readFile(full);
|
|
201
|
+
try {
|
|
202
|
+
files.push({ path: relativePosix(repoRoot, full), content: decoder.decode(bytes) });
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
throw new Error(`mini_app_publish: ${relativePosix(repoRoot, full)} is not valid UTF-8 text; a design folder holds text files only`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
throw new Error(`mini_app_publish: ${relativePosix(repoRoot, full)} is not a plain file or directory; a design folder holds plain files only`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
await walk(dirAbs);
|
|
214
|
+
return files;
|
|
215
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-5294 / DX-6477 - what a mini app target's build wrote, folded into ONE self-contained HTML document. ONE module for it: `mini_app_publish` (`mini-app-build.ts`)
|
|
3
|
+
* and the designer's local viewer command (`.danxbot/mini-apps/check-design.mjs`) both build the page here, so the page a designer checks is the page the publish sends,
|
|
4
|
+
* with the same `</script` and `<!--` escapes and the same refusals.
|
|
5
|
+
*/
|
|
6
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { relativePosix } from "./mini-app-checkout.js";
|
|
9
|
+
export const INDEX_HTML = "index.html";
|
|
10
|
+
/**
|
|
11
|
+
* Two sequences in a script's text break the HTML parse of an inline script, and each has a spelling the JS parser reads as the same text inside a
|
|
12
|
+
* string, regex or template: `</script` would end the element early (`<\/script`), and `<!--` (a bundled HTML highlighter's comment regex) puts the HTML
|
|
13
|
+
* parser in its "escaped" state, where a later `<script` (the same highlighter's tag regex) makes the real `</script>` stop ending the element, so the
|
|
14
|
+
* page's own closing tags become JS text ("Unexpected token '<'", DX-5781). `<!--` becomes backslash, `x3C`, `!--`: the less-than as a hex escape, which
|
|
15
|
+
* a string, a regex (with or without the `u` flag) and a template all read as the same character.
|
|
16
|
+
*/
|
|
17
|
+
const ESCAPED_COMMENT_OPEN = "\\x3C!--";
|
|
18
|
+
function escapeScript(js) {
|
|
19
|
+
return js.replace(/<\/(script)/gi, "<\\/$1").replace(/<!--/g, ESCAPED_COMMENT_OPEN);
|
|
20
|
+
}
|
|
21
|
+
function refEndsWith(ref, name) {
|
|
22
|
+
const clean = ref.split(/[?#]/)[0];
|
|
23
|
+
return clean === name || clean.endsWith(`/${name}`);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Fold the built page's one script and one stylesheet into the HTML: the `<script ... src>` that references the JS file becomes an
|
|
27
|
+
* inline module script (with `</script` escaped), and the `<link rel="stylesheet" href>` that references the CSS file becomes a
|
|
28
|
+
* `<style>`. A tag the build did not write for that file is a failure of this fold and throws; the document is never checked for
|
|
29
|
+
* relative references here (the dashboard's parse5 check owns that).
|
|
30
|
+
*/
|
|
31
|
+
export function inlineAssets(html, js, css) {
|
|
32
|
+
let out = html;
|
|
33
|
+
let scripts = 0;
|
|
34
|
+
out = out.replace(/<script\b[^>]*\bsrc=(["'])([^"']*)\1[^>]*>\s*<\/script>/gi, (tag, _quote, src) => {
|
|
35
|
+
if (!refEndsWith(src, js.name))
|
|
36
|
+
return tag;
|
|
37
|
+
scripts += 1;
|
|
38
|
+
return `<script type="module">\n${escapeScript(js.text)}\n</script>`;
|
|
39
|
+
});
|
|
40
|
+
if (scripts !== 1)
|
|
41
|
+
throw new Error(`mini_app_publish: the built index.html has ${scripts} <script> tags for ${js.name}; expected exactly 1`);
|
|
42
|
+
if (/<\/style/i.test(css.text)) {
|
|
43
|
+
throw new Error(`mini_app_publish: ${css.name} contains "</style" and cannot be inlined into a <style> element`);
|
|
44
|
+
}
|
|
45
|
+
let links = 0;
|
|
46
|
+
out = out.replace(/<link\b[^>]*>/gi, (tag) => {
|
|
47
|
+
if (!/\brel=(["'])stylesheet\1/i.test(tag))
|
|
48
|
+
return tag;
|
|
49
|
+
const href = /\bhref=(["'])([^"']*)\1/i.exec(tag)?.[2];
|
|
50
|
+
if (href === undefined || !refEndsWith(href, css.name))
|
|
51
|
+
return tag;
|
|
52
|
+
links += 1;
|
|
53
|
+
return `<style>\n${css.text}\n</style>`;
|
|
54
|
+
});
|
|
55
|
+
if (links !== 1)
|
|
56
|
+
throw new Error(`mini_app_publish: the built index.html has ${links} stylesheet links for ${css.name}; expected exactly 1`);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
async function listFiles(dir) {
|
|
60
|
+
const found = [];
|
|
61
|
+
const walk = async (current) => {
|
|
62
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
63
|
+
const full = path.join(current, entry.name);
|
|
64
|
+
if (entry.isDirectory())
|
|
65
|
+
await walk(full);
|
|
66
|
+
else if (entry.isFile())
|
|
67
|
+
found.push(full);
|
|
68
|
+
// DX-5294: a link (or any other entry) is named, never silently skipped: the output must be plain files the build wrote
|
|
69
|
+
else
|
|
70
|
+
throw new Error(`mini_app_publish: the build wrote ${relativePosix(dir, full)}, which is not a plain file: the output must be only ${INDEX_HTML}, one .js and one .css`);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
await walk(dir);
|
|
74
|
+
return found.sort();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Read what the build wrote. The output directory must hold exactly `index.html` (a build tool may keep its path below the root, so it may sit in
|
|
78
|
+
* a subdirectory), ONE `.js` and ONE `.css`: anything else, such as a second chunk or an emitted asset, fails naming the file.
|
|
79
|
+
*/
|
|
80
|
+
export async function readBuildOutput(outDir) {
|
|
81
|
+
const files = await listFiles(outDir);
|
|
82
|
+
const slots = { html: [], js: [], css: [] };
|
|
83
|
+
const extras = [];
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
const ext = path.extname(file).toLowerCase();
|
|
86
|
+
if (path.basename(file) === INDEX_HTML)
|
|
87
|
+
slots.html.push(file);
|
|
88
|
+
else if (ext === ".js")
|
|
89
|
+
slots.js.push(file);
|
|
90
|
+
else if (ext === ".css")
|
|
91
|
+
slots.css.push(file);
|
|
92
|
+
else
|
|
93
|
+
extras.push(file);
|
|
94
|
+
}
|
|
95
|
+
extras.push(...slots.html.slice(1), ...slots.js.slice(1), ...slots.css.slice(1));
|
|
96
|
+
if (extras.length > 0) {
|
|
97
|
+
const named = extras.map((f) => relativePosix(outDir, f)).sort();
|
|
98
|
+
throw new Error(`mini_app_publish: the build wrote an unexpected file ${named.join(", ")}: the output must be only ${INDEX_HTML}, one .js and one .css`);
|
|
99
|
+
}
|
|
100
|
+
for (const [kind, label] of [["html", INDEX_HTML], ["js", ".js file"], ["css", ".css file"]]) {
|
|
101
|
+
if (slots[kind].length === 0)
|
|
102
|
+
throw new Error(`mini_app_publish: the build wrote no ${label}`);
|
|
103
|
+
}
|
|
104
|
+
const asset = async (file) => ({ name: path.basename(file), text: await readFile(file, "utf8") });
|
|
105
|
+
return { html: await asset(slots.html[0]), js: await asset(slots.js[0]), css: await asset(slots.css[0]) };
|
|
106
|
+
}
|
|
107
|
+
/** The build output folder as the one page the dashboard receives: read it (exactly `index.html`, one `.js`, one `.css`) and fold the script and stylesheet in. */
|
|
108
|
+
export async function foldBuildOutput(outDir) {
|
|
109
|
+
const built = await readBuildOutput(outDir);
|
|
110
|
+
return inlineAssets(built.html.text, built.js, built.css);
|
|
111
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-5294 - the `mini_app_publish` flow: build a page with the repo's OWN mini app (a build command a target names, run in the agent's
|
|
3
|
+
* checkout) and publish it as a revision of one of an existing card's designs, or as the first revision of a new design on it
|
|
4
|
+
* (DX-4077, DX-5193). It replaces `design_publish`.
|
|
5
|
+
*
|
|
6
|
+
* It lives in this package, not behind `50arms_api`, because it is agent-specific: the build runs where the source is (the agent's
|
|
7
|
+
* own checkout or worktree) and the dashboard holds no connected repo's source. Every dashboard call goes through the same client
|
|
8
|
+
* `50arms_api` uses, so it carries the session's credential, trace and session headers.
|
|
9
|
+
*
|
|
10
|
+
* The design goes on an EXISTING card (`destination.card_id`, any card but an Epic or Feature, DX-5164): this tool never creates a card.
|
|
11
|
+
* `destination.design_no` adds a revision to that existing design (`POST .../designs/<no>/revisions`); `destination.design_title` starts a
|
|
12
|
+
* new design (`POST .../designs`, the title fixed for good). Exactly one of the two, refused before any call.
|
|
13
|
+
*
|
|
14
|
+
* `target.repo` names the repo whose mini app builds the page; omitted, it is the repo of the card's board (the danxbot repo's own mini
|
|
15
|
+
* app is reached by naming it).
|
|
16
|
+
*
|
|
17
|
+
* ORDER, so a failure leaves as little behind as it can: the arguments, then the card's board, then the repo and its targets (the
|
|
18
|
+
* dashboard answers; a refusal comes back verbatim), then the checkout checks, then the build, then the screenshot, and only THEN the
|
|
19
|
+
* revision is posted. A failed check, build or screenshot never leaves anything on the card; a refused revision comes back verbatim with
|
|
20
|
+
* the `card_id` and the built page's path.
|
|
21
|
+
*/
|
|
22
|
+
import { buildMiniApp, writeLocalHtml } from "./mini-app-build.js";
|
|
23
|
+
import { checkTarget, parseMiniAppTargets, pickMiniAppTarget, readHeadCommit, readSourceFiles, resolveDesignDir, resolveRepoRoot, } from "./mini-app-checkout.js";
|
|
24
|
+
import { captureScreenshot } from "./design-screenshot.js";
|
|
25
|
+
import { danxbotApi } from "./handlers.js";
|
|
26
|
+
function fail(message) {
|
|
27
|
+
throw new Error(`mini_app_publish: ${message}`);
|
|
28
|
+
}
|
|
29
|
+
function field(body, name, where) {
|
|
30
|
+
const value = body?.[name];
|
|
31
|
+
if (typeof value !== "string" || value === "")
|
|
32
|
+
fail(`${where} answered without "${name}"`);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
/** DX-5512: a board ref on the wire is a positive integer; anything else is a dashboard fault, never repaired. */
|
|
36
|
+
function refField(body, name, where) {
|
|
37
|
+
const value = body?.[name];
|
|
38
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1)
|
|
39
|
+
fail(`${where} answered without "${name}" as a positive integer`);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
/** The board the card lives on, read from the card: a revision goes on the card's own board. */
|
|
43
|
+
async function readCardBoard(client, cardId) {
|
|
44
|
+
const card = await danxbotApi(client, {
|
|
45
|
+
method: "GET",
|
|
46
|
+
path: `/api/issues/${encodeURIComponent(cardId)}`,
|
|
47
|
+
query: { fields: { board_id: true } },
|
|
48
|
+
});
|
|
49
|
+
if (!card.ok)
|
|
50
|
+
return card;
|
|
51
|
+
return { ok: true, board: refField(card.body, "board_id", `GET /api/issues/${cardId}`) };
|
|
52
|
+
}
|
|
53
|
+
export async function miniAppPublish(client, args, deps) {
|
|
54
|
+
// DX-4729: refused before any call, so a blank note never leaves a built page or a screenshot behind.
|
|
55
|
+
if (args.note.trim() === "")
|
|
56
|
+
fail("`note` is required: say what changed since the last revision, which decision note it answers and what is still open");
|
|
57
|
+
const { card_id: cardId } = args.destination;
|
|
58
|
+
// DX-5193: exactly one of design_no / design_title, refused before any call so a bad pair never builds a page.
|
|
59
|
+
const hasNo = args.destination.design_no !== undefined;
|
|
60
|
+
const hasTitle = args.destination.design_title !== undefined;
|
|
61
|
+
if (hasNo === hasTitle) {
|
|
62
|
+
fail("pass exactly one of `destination.design_no` (add a revision to that design of the card) or `destination.design_title` (start a new design on the card)");
|
|
63
|
+
}
|
|
64
|
+
if (args.destination.design_title?.trim() === "")
|
|
65
|
+
fail("`destination.design_title` must not be blank");
|
|
66
|
+
// DX-5856: a new design names the element it decides (its design problem is that element's); a revision stays on its design's problem.
|
|
67
|
+
if (hasTitle && args.destination.design_element === undefined)
|
|
68
|
+
fail("`destination.design_element` is required with `destination.design_title`: name the one thing this design decides (e.g. \"Indicator pill\")");
|
|
69
|
+
if (hasNo && args.destination.design_element !== undefined)
|
|
70
|
+
fail("`destination.design_element` goes only with `destination.design_title`: a revision stays on its design's problem");
|
|
71
|
+
if (args.destination.design_element?.trim() === "")
|
|
72
|
+
fail("`destination.design_element` must not be blank");
|
|
73
|
+
const boardOrRefusal = await readCardBoard(client, cardId);
|
|
74
|
+
if (!boardOrRefusal.ok)
|
|
75
|
+
return boardOrRefusal;
|
|
76
|
+
const { board } = boardOrRefusal;
|
|
77
|
+
// DX-5294: an explicit `target.repo` (a repo's name) names the repo (danxbot's own mini app); otherwise the dashboard owns board -> repo: the repo is
|
|
78
|
+
// read from the board, never parsed out of its id and never from an env var.
|
|
79
|
+
let repo = args.target.repo;
|
|
80
|
+
if (repo === undefined) {
|
|
81
|
+
const boardRow = await danxbotApi(client, { method: "GET", path: `/api/boards/${board}` });
|
|
82
|
+
if (!boardRow.ok)
|
|
83
|
+
return boardRow;
|
|
84
|
+
repo = field(boardRow.body, "repo_name", `GET /api/boards/${board}`);
|
|
85
|
+
}
|
|
86
|
+
// The targets are a dashboard setting, never a file in the repo.
|
|
87
|
+
const repoRow = await danxbotApi(client, {
|
|
88
|
+
method: "GET",
|
|
89
|
+
path: `/api/repos/${repo}`,
|
|
90
|
+
query: { fields: { mini_app_targets: true } },
|
|
91
|
+
});
|
|
92
|
+
if (!repoRow.ok)
|
|
93
|
+
return repoRow;
|
|
94
|
+
const target = pickMiniAppTarget(parseMiniAppTargets(repoRow.body, repo), args.target.name, repo);
|
|
95
|
+
const dirAbs = await resolveDesignDir(args.dir, deps.cwd);
|
|
96
|
+
const repoRoot = await resolveRepoRoot(dirAbs);
|
|
97
|
+
const checked = await checkTarget(target, repo, repoRoot, dirAbs, deps.platform ?? process.platform);
|
|
98
|
+
const sourceFiles = await readSourceFiles(checked.dirAbs, repoRoot);
|
|
99
|
+
const builtFromCommit = await readHeadCommit(repoRoot);
|
|
100
|
+
const { html, buildId } = await buildMiniApp({ command: target.command, repoRoot, designDirRel: checked.designDirRel }, deps.buildTimeoutMs, deps.buildPipeGraceMs);
|
|
101
|
+
const localHtmlPath = await writeLocalHtml(buildId, html);
|
|
102
|
+
const shot = await captureScreenshot(html, { rootAbs: repoRoot, cwd: deps.cwd }, args.option_groups ?? []);
|
|
103
|
+
const designRoute = hasTitle
|
|
104
|
+
? `/api/issues/${encodeURIComponent(cardId)}/designs`
|
|
105
|
+
: `/api/issues/${encodeURIComponent(cardId)}/designs/${args.destination.design_no}/revisions`;
|
|
106
|
+
let published;
|
|
107
|
+
try {
|
|
108
|
+
published = await danxbotApi(client, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
path: designRoute,
|
|
111
|
+
body: {
|
|
112
|
+
...(hasTitle ? { title: args.destination.design_title, element: args.destination.design_element } : {}),
|
|
113
|
+
note: args.note,
|
|
114
|
+
target: target.name,
|
|
115
|
+
design_dir: checked.designDirRel,
|
|
116
|
+
source_files: sourceFiles,
|
|
117
|
+
bundle_html: html,
|
|
118
|
+
built_from_commit: builtFromCommit,
|
|
119
|
+
...(shot.png_base64 === null ? {} : { screenshot_png_base64: shot.png_base64 }),
|
|
120
|
+
...(args.option_groups === undefined ? {} : { option_groups: args.option_groups }),
|
|
121
|
+
checklist: args.checklist,
|
|
122
|
+
browser_check: args.browser_check,
|
|
123
|
+
...(args.problem_statement === undefined ? {} : { problem_statement: args.problem_statement }),
|
|
124
|
+
...(args.problem_summary === undefined ? {} : { problem_summary: args.problem_summary }),
|
|
125
|
+
...(args.problem_context === undefined ? {} : { problem_context: args.problem_context }),
|
|
126
|
+
},
|
|
127
|
+
board,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
// A network or server fault: the page is built and still matters to the retry.
|
|
132
|
+
throw new Error(`${err.message} (no revision was published to ${cardId}; the page is at ${localHtmlPath})`);
|
|
133
|
+
}
|
|
134
|
+
if (!published.ok)
|
|
135
|
+
return { ...published, card_id: cardId, local_html_path: localHtmlPath };
|
|
136
|
+
const answer = published.body;
|
|
137
|
+
if (typeof answer?.design_no !== "number")
|
|
138
|
+
fail(`POST ${designRoute} answered without a design_no`);
|
|
139
|
+
if (typeof answer.revision_no !== "number")
|
|
140
|
+
fail(`POST ${designRoute} answered without a revision_no`);
|
|
141
|
+
const body = {
|
|
142
|
+
card_id: cardId,
|
|
143
|
+
design_no: answer.design_no,
|
|
144
|
+
revision_no: answer.revision_no,
|
|
145
|
+
dashboard_url: `${deps.baseUrl.replace(/\/+$/, "")}/board/${cardId}`,
|
|
146
|
+
local_html_path: localHtmlPath,
|
|
147
|
+
screenshot: shot.screenshot,
|
|
148
|
+
browser_check: args.browser_check,
|
|
149
|
+
...(args.browser_check.status === "not_checked"
|
|
150
|
+
? { warning: `Published WITHOUT a browser check (${args.browser_check.note}). The revision carries a big red warning on its design problem and in the full-screen design dialog, and the operator is told. Check it in a browser and publish a new revision that says "checked".` }
|
|
151
|
+
: {}),
|
|
152
|
+
};
|
|
153
|
+
return { ok: true, status: published.status, body };
|
|
154
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-6070 - the `mini_app_restore` flow: put one revision of a design back on disk. A design's source (its gitignored folder of text files)
|
|
3
|
+
* is stored with EVERY revision on the dashboard, but a designer in another worktree, or on another machine, has no folder to revise. This
|
|
4
|
+
* reads one revision's stored source (`GET /api/issues/<card>/designs/<no>/revisions/<rev>/source`) and writes each file at its repo-relative
|
|
5
|
+
* path under the agent's repo root (resolved as `mini_app_publish` resolves it, `resolveRepoRoot`).
|
|
6
|
+
*
|
|
7
|
+
* It lives here, not behind `50arms_api`, because it writes local files.
|
|
8
|
+
*
|
|
9
|
+
* ALL-OR-NOTHING: every path is checked, and every existing file compared, BEFORE the first write. A path that leaves the revision's
|
|
10
|
+
* `design_dir` (absolute, `..`, a backslash, outside the folder) refuses the whole restore; so does an existing file with different content
|
|
11
|
+
* (that is unpublished work, never overwritten), naming each such file. A file with equal content is `unchanged`; a file in the folder the
|
|
12
|
+
* revision lacks is left alone and named in `extra_local`.
|
|
13
|
+
*/
|
|
14
|
+
import { lstat, mkdir, readFile, readdir, realpath, writeFile } from "node:fs/promises";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { isStrictlyInside, relativePosix, resolveRepoRoot } from "./mini-app-checkout.js";
|
|
17
|
+
import { danxbotApi } from "./handlers.js";
|
|
18
|
+
function fail(message) {
|
|
19
|
+
throw new Error(`mini_app_restore: ${message}`);
|
|
20
|
+
}
|
|
21
|
+
/** A repo-relative POSIX path that is safe to join under a root: non-empty, not absolute (either flavour), no backslash, no drive letter, no `.`/`..`/empty segment. */
|
|
22
|
+
function isSafeRelative(p) {
|
|
23
|
+
if (p === "" || p.includes("\\") || p.includes("\0") || p.startsWith("/") || /^[A-Za-z]:/.test(p))
|
|
24
|
+
return false;
|
|
25
|
+
return p.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
26
|
+
}
|
|
27
|
+
function parseSource(body, where) {
|
|
28
|
+
const b = body;
|
|
29
|
+
if (typeof b?.design_dir !== "string" || typeof b.built_from_commit !== "string" || !Array.isArray(b.source_files)) {
|
|
30
|
+
fail(`${where} answered without design_dir, built_from_commit and source_files`);
|
|
31
|
+
}
|
|
32
|
+
const files = b.source_files.map((f, i) => {
|
|
33
|
+
const file = f;
|
|
34
|
+
if (typeof file?.path !== "string" || typeof file.content !== "string")
|
|
35
|
+
fail(`${where} answered source_files[${i}] without a string path and content`);
|
|
36
|
+
return { path: file.path, content: file.content };
|
|
37
|
+
});
|
|
38
|
+
return { design_dir: b.design_dir, built_from_commit: b.built_from_commit, source_files: files };
|
|
39
|
+
}
|
|
40
|
+
/** The design's latest revision number, from the card's `designs` relation. */
|
|
41
|
+
async function latestRevisionNo(client, cardId, designNo) {
|
|
42
|
+
const card = await danxbotApi(client, { method: "GET", path: `/api/issues/${encodeURIComponent(cardId)}`, query: { fields: { designs: true } } });
|
|
43
|
+
if (!card.ok)
|
|
44
|
+
return card;
|
|
45
|
+
const designs = card.body?.designs;
|
|
46
|
+
if (!Array.isArray(designs))
|
|
47
|
+
fail(`GET /api/issues/${cardId} answered without a designs array`);
|
|
48
|
+
const rows = designs;
|
|
49
|
+
const design = rows.find((d) => d.design_no === designNo);
|
|
50
|
+
if (design === undefined)
|
|
51
|
+
fail(`card ${cardId} has no design ${designNo}; its designs: ${rows.map((d) => String(d.design_no)).join(", ") || "none"}`);
|
|
52
|
+
if (typeof design.latest_revision_no !== "number")
|
|
53
|
+
fail(`card ${cardId} design ${designNo} answered without a latest_revision_no`);
|
|
54
|
+
return { ok: true, revisionNo: design.latest_revision_no };
|
|
55
|
+
}
|
|
56
|
+
/** Every file under `dir` (absolute), repo-relative POSIX; nothing when `dir` does not exist. */
|
|
57
|
+
async function listFolder(dir, repoRoot) {
|
|
58
|
+
let entries;
|
|
59
|
+
try {
|
|
60
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err.code === "ENOENT")
|
|
64
|
+
return [];
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const full = path.join(dir, entry.name);
|
|
70
|
+
if (entry.isDirectory())
|
|
71
|
+
out.push(...(await listFolder(full, repoRoot)));
|
|
72
|
+
else
|
|
73
|
+
out.push(relativePosix(repoRoot, full));
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** What is at `abs`: nothing, a plain file (its text), or something else (a directory, a link) that is in the way. */
|
|
78
|
+
async function existingAt(abs) {
|
|
79
|
+
let info;
|
|
80
|
+
try {
|
|
81
|
+
info = await lstat(abs);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
const code = err.code;
|
|
85
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
86
|
+
return { kind: "missing" };
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
if (!info.isFile())
|
|
90
|
+
return { kind: "other" };
|
|
91
|
+
return { kind: "file", content: (await readFile(abs)).toString("utf8") };
|
|
92
|
+
}
|
|
93
|
+
/** The nearest existing ancestor of `abs`, resolved through symlinks: a restore never writes through a link that leaves the repo. */
|
|
94
|
+
async function realAncestor(abs) {
|
|
95
|
+
let current = abs;
|
|
96
|
+
for (;;) {
|
|
97
|
+
try {
|
|
98
|
+
return await realpath(current);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
const code = err.code;
|
|
102
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
103
|
+
throw err;
|
|
104
|
+
const parent = path.dirname(current);
|
|
105
|
+
if (parent === current)
|
|
106
|
+
throw err;
|
|
107
|
+
current = parent;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export async function miniAppRestore(client, args, deps) {
|
|
112
|
+
let revisionNo = args.revision_no;
|
|
113
|
+
if (revisionNo === undefined) {
|
|
114
|
+
const latest = await latestRevisionNo(client, args.card_id, args.design_no);
|
|
115
|
+
if (!latest.ok)
|
|
116
|
+
return latest;
|
|
117
|
+
revisionNo = latest.revisionNo;
|
|
118
|
+
}
|
|
119
|
+
const route = `/api/issues/${encodeURIComponent(args.card_id)}/designs/${args.design_no}/revisions/${revisionNo}/source`;
|
|
120
|
+
const answer = await danxbotApi(client, { method: "GET", path: route });
|
|
121
|
+
if (!answer.ok)
|
|
122
|
+
return answer;
|
|
123
|
+
const source = parseSource(answer.body, `GET ${route}`);
|
|
124
|
+
if (!isSafeRelative(source.design_dir))
|
|
125
|
+
fail(`the revision's design_dir "${source.design_dir}" is not a safe repo-relative path; nothing was written`);
|
|
126
|
+
const designPrefix = `${source.design_dir}/`;
|
|
127
|
+
const outside = source.source_files.filter((f) => !isSafeRelative(f.path) || !f.path.startsWith(designPrefix));
|
|
128
|
+
if (outside.length > 0) {
|
|
129
|
+
fail(`${outside.length} path(s) sit outside the design folder "${source.design_dir}" (absolute, "..", a backslash or another folder); nothing was written: ${outside.map((f) => JSON.stringify(f.path)).join(", ")}`);
|
|
130
|
+
}
|
|
131
|
+
const repoRoot = await resolveRepoRoot(deps.cwd);
|
|
132
|
+
const targets = source.source_files.map((f) => ({ ...f, abs: path.join(repoRoot, ...f.path.split("/")) }));
|
|
133
|
+
const leaving = [];
|
|
134
|
+
const differing = [];
|
|
135
|
+
const unchanged = [];
|
|
136
|
+
const toWrite = [];
|
|
137
|
+
for (const t of targets) {
|
|
138
|
+
const ancestor = await realAncestor(t.abs);
|
|
139
|
+
if (ancestor !== repoRoot && !isStrictlyInside(repoRoot, ancestor)) {
|
|
140
|
+
leaving.push(t.path);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const existing = await existingAt(t.abs);
|
|
144
|
+
if (existing.kind === "missing")
|
|
145
|
+
toWrite.push(t);
|
|
146
|
+
else if (existing.kind === "file" && existing.content === t.content)
|
|
147
|
+
unchanged.push(t.path);
|
|
148
|
+
else
|
|
149
|
+
differing.push(t.path);
|
|
150
|
+
}
|
|
151
|
+
if (leaving.length > 0)
|
|
152
|
+
fail(`${leaving.length} path(s) resolve outside the repo checkout through a link; nothing was written: ${leaving.join(", ")}`);
|
|
153
|
+
if (differing.length > 0) {
|
|
154
|
+
fail(`${differing.length} existing local file(s) differ from revision ${revisionNo} (unpublished work is never overwritten); nothing was written: ${differing.join(", ")}. ` +
|
|
155
|
+
"Publish or move them first, or restore into a checkout that lacks the folder.");
|
|
156
|
+
}
|
|
157
|
+
for (const t of toWrite) {
|
|
158
|
+
await mkdir(path.dirname(t.abs), { recursive: true });
|
|
159
|
+
await writeFile(t.abs, t.content, "utf8");
|
|
160
|
+
}
|
|
161
|
+
const restored = new Set(source.source_files.map((f) => f.path));
|
|
162
|
+
const extraLocal = (await listFolder(path.join(repoRoot, ...source.design_dir.split("/")), repoRoot)).filter((p) => !restored.has(p)).sort();
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
status: answer.status,
|
|
166
|
+
body: {
|
|
167
|
+
design_dir: source.design_dir,
|
|
168
|
+
revision_no: revisionNo,
|
|
169
|
+
built_from_commit: source.built_from_commit,
|
|
170
|
+
written: toWrite.map((t) => t.path),
|
|
171
|
+
unchanged,
|
|
172
|
+
extra_local: extraLocal,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|