@capacms/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -2
- package/bin/capa.mjs +26 -0
- package/lib/argv.mjs +76 -0
- package/lib/commands.mjs +215 -0
- package/lib/credentials.mjs +450 -0
- package/lib/diff.mjs +45 -0
- package/lib/init.mjs +365 -0
- package/lib/main.mjs +477 -0
- package/lib/output.mjs +59 -0
- package/lib/sdk.mjs +47 -0
- package/lib/session.mjs +216 -0
- package/lib/toml.mjs +100 -0
- package/package.json +24 -3
package/lib/init.mjs
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* init.mjs — `capa init claude|codex|cursor`: register `capa mcp` with an
|
|
3
|
+
* agent, in that agent's own config format, and nothing else in the file.
|
|
4
|
+
*
|
|
5
|
+
* What each writes (checked against each client's docs and its installed CLI
|
|
6
|
+
* on 2026-10-02: Claude Code 2.1.288, Codex 0.159.3, Cursor Agent 2026.01.23):
|
|
7
|
+
*
|
|
8
|
+
* claude ./.mcp.json, `mcpServers.capa` = { type: "stdio", command, args },
|
|
9
|
+
* which `claude` loads for this project (and asks once before
|
|
10
|
+
* trusting). --scope user or local runs `claude mcp add-json`
|
|
11
|
+
* instead, since those scopes live in Claude's own ~/.claude.json.
|
|
12
|
+
* codex $CODEX_HOME/config.toml (~/.codex/config.toml), the section
|
|
13
|
+
* `[mcp_servers.capa]`; --project writes ./.codex/config.toml,
|
|
14
|
+
* which Codex reads for trusted projects only.
|
|
15
|
+
* cursor ./.cursor/mcp.json, `mcpServers.capa` = { type: "stdio", command, args };
|
|
16
|
+
* --global writes ~/.cursor/mcp.json.
|
|
17
|
+
*
|
|
18
|
+
* NO KEY IS EVER WRITTEN. The server is `capa mcp`, which reads the key
|
|
19
|
+
* `capa login` stored in the keychain (or CAPA_KEY from its environment), so a
|
|
20
|
+
* config file holds a command and nothing secret, and a project file is safe
|
|
21
|
+
* to commit. Codex starts servers with a cleared environment, so its section
|
|
22
|
+
* lists the CAPA_ variables it may pass through (`env_vars`): names, never
|
|
23
|
+
* values. Cursor passes HOME and PATH and none of the CAPA_ variables, and
|
|
24
|
+
* this version of cursor-agent sends `${env:NAME}` through as those literal
|
|
25
|
+
* characters, so its entry carries no `env` at all: `capa mcp` there reads the
|
|
26
|
+
* saved profile, which `--profile` can pin in the arguments.
|
|
27
|
+
*
|
|
28
|
+
* Every other server in the file is kept, and the file's own formatting with
|
|
29
|
+
* it where the format allows (TOML is edited by lines; JSON is re-printed with
|
|
30
|
+
* two-space indentation). The change is printed first, as a diff, and written
|
|
31
|
+
* only with --yes or a "y" at the prompt; --dry-run prints and stops.
|
|
32
|
+
*
|
|
33
|
+
* A config file that is a symlink is written through, and the plan names the
|
|
34
|
+
* file the link points at. A file in the project (.mcp.json, .codex/,
|
|
35
|
+
* .cursor/, CLAUDE.md, AGENTS.md) must land in the project: a cloned
|
|
36
|
+
* repository can carry a link to any file of the person's, so one whose link
|
|
37
|
+
* or folder points outside it is refused before it is read.
|
|
38
|
+
*/
|
|
39
|
+
import fs from "node:fs";
|
|
40
|
+
import os from "node:os";
|
|
41
|
+
import path from "node:path";
|
|
42
|
+
import { lineDiff } from "./diff.mjs";
|
|
43
|
+
import { CliError, EXIT } from "./output.mjs";
|
|
44
|
+
import { setServerSection, tomlArray, tomlString } from "./toml.mjs";
|
|
45
|
+
|
|
46
|
+
export const CLIENTS = ["claude", "codex", "cursor"];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The CAPA_ variables Codex may hand to `capa mcp`. It starts an MCP server
|
|
50
|
+
* with only HOME, PATH, USER and a few others, so without these a key in the
|
|
51
|
+
* environment, a profile choice or a config dir would not arrive.
|
|
52
|
+
*/
|
|
53
|
+
export const CODEX_ENV_VARS = [
|
|
54
|
+
"CAPA_KEY",
|
|
55
|
+
"CAPA_API_URL",
|
|
56
|
+
"CAPA_API_VERSION",
|
|
57
|
+
"CAPA_TENANT_ID",
|
|
58
|
+
"CAPA_PROFILE",
|
|
59
|
+
"CAPA_CONFIG_DIR",
|
|
60
|
+
"XDG_CONFIG_HOME",
|
|
61
|
+
"CAPA_CREDENTIAL_STORE",
|
|
62
|
+
"CAPA_KEYCHAIN",
|
|
63
|
+
"CAPA_UPLOAD_URL",
|
|
64
|
+
"CAPA_UPLOAD_ROOT",
|
|
65
|
+
"CAPA_UPLOAD_ALLOW",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Tools a headless Codex run must still ask about when --approve lets it run
|
|
70
|
+
* the rest: a publish changes what the site shows, and an upload sends a file
|
|
71
|
+
* from this machine to the project's media.
|
|
72
|
+
*/
|
|
73
|
+
export const ASK_BEFORE = ["capa_publish_entry", "capa_unpublish_entry", "capa_upload_media"];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A PATH entry that is there only for this run: npx's temporary install
|
|
77
|
+
* (`.../_npx/<hash>/node_modules/.bin`) and `npm run`'s project bin. A
|
|
78
|
+
* `capa` found there is gone, or off PATH, when an agent starts the server.
|
|
79
|
+
*/
|
|
80
|
+
function borrowedBin(dir) {
|
|
81
|
+
const parts = path.resolve(dir).split(path.sep);
|
|
82
|
+
return parts.includes("_npx") || (parts.at(-1) === ".bin" && parts.at(-2) === "node_modules");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** An executable `name` on `PATH`, skipping the entries npx and npm run add for one run, or null. */
|
|
86
|
+
function onPath(name, env) {
|
|
87
|
+
const exts = process.platform === "win32" ? (env.PATHEXT || ".CMD;.EXE").split(";") : [""];
|
|
88
|
+
for (const dir of String(env.PATH || "").split(path.delimiter).filter(Boolean)) {
|
|
89
|
+
if (borrowedBin(dir)) continue;
|
|
90
|
+
for (const ext of exts) {
|
|
91
|
+
const candidate = path.join(dir, `${name}${ext}`);
|
|
92
|
+
try {
|
|
93
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
94
|
+
return candidate;
|
|
95
|
+
} catch {
|
|
96
|
+
// not here
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The command a client runs: --command when given, else `capa` when a global
|
|
105
|
+
* install put it on PATH, else `npx -y @capacms/cli`, which fetches it.
|
|
106
|
+
*/
|
|
107
|
+
export function serverCommand(io, flags) {
|
|
108
|
+
const args = ["mcp", ...(flags.profile ? ["--profile", flags.profile] : [])];
|
|
109
|
+
if (typeof flags.command === "string" && flags.command) return { command: flags.command, args };
|
|
110
|
+
if (onPath("capa", io.env)) return { command: "capa", args };
|
|
111
|
+
return { command: "npx", args: ["-y", "@capacms/cli", ...args] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readText(file) {
|
|
115
|
+
try {
|
|
116
|
+
return fs.readFileSync(file, "utf8");
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error.code === "ENOENT") return null;
|
|
119
|
+
throw new CliError(EXIT.config, `could not read ${file}: ${error.message}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `text` with `mcpServers[name]` set to `entry`, every other key kept in its place. */
|
|
124
|
+
export function mergeMcpJson(text, name, entry, file) {
|
|
125
|
+
let doc = {};
|
|
126
|
+
if (text && text.trim()) {
|
|
127
|
+
try {
|
|
128
|
+
doc = JSON.parse(text);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new CliError(EXIT.config, `${file} is not plain JSON (${error.message}), so it is left alone. Add this under "mcpServers" by hand: "${name}": ${JSON.stringify(entry)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new CliError(EXIT.config, `${file} is not a JSON object, so it is left alone.`);
|
|
134
|
+
if (doc.mcpServers !== undefined && (typeof doc.mcpServers !== "object" || doc.mcpServers === null || Array.isArray(doc.mcpServers))) {
|
|
135
|
+
throw new CliError(EXIT.config, `${file} has an "mcpServers" that is not an object, so it is left alone.`);
|
|
136
|
+
}
|
|
137
|
+
const next = { ...doc, mcpServers: { ...(doc.mcpServers ?? {}), [name]: entry } };
|
|
138
|
+
return `${JSON.stringify(next, null, 2)}\n`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The Codex section: the server, the variables it may receive, and with --approve which tools still ask. */
|
|
142
|
+
export function codexSection(name, server, { approve = false } = {}) {
|
|
143
|
+
const lines = [
|
|
144
|
+
`[mcp_servers.${name}]`,
|
|
145
|
+
`command = ${tomlString(server.command)}`,
|
|
146
|
+
`args = ${tomlArray(server.args)}`,
|
|
147
|
+
`env_vars = ${tomlArray(CODEX_ENV_VARS)}`,
|
|
148
|
+
];
|
|
149
|
+
if (approve) {
|
|
150
|
+
lines.push(`default_tools_approval_mode = "approve"`);
|
|
151
|
+
for (const tool of ASK_BEFORE) lines.push("", `[mcp_servers.${name}.tools.${tool}]`, `approval_mode = "prompt"`);
|
|
152
|
+
}
|
|
153
|
+
return `${lines.join("\n")}\n`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* What `capa init <client>` would change: a list of steps, each a file write
|
|
158
|
+
* (`{ file, before, after }`) or a command to run (`{ run: [cmd, ...args] }`).
|
|
159
|
+
* Pure apart from reading the files it would change.
|
|
160
|
+
*/
|
|
161
|
+
export function planInit(client, io, flags) {
|
|
162
|
+
const name = typeof flags.name === "string" && flags.name ? flags.name : "capa";
|
|
163
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new CliError(EXIT.usage, `--name ${name}: use letters, digits, dashes and underscores.`);
|
|
164
|
+
const server = serverCommand(io, flags);
|
|
165
|
+
const home = io.env.HOME || os.homedir();
|
|
166
|
+
if (client === "claude") {
|
|
167
|
+
const scope = flags.scope ?? "project";
|
|
168
|
+
if (!["project", "user", "local"].includes(scope)) throw new CliError(EXIT.usage, `--scope ${scope}: Claude Code's scopes are project, user and local.`);
|
|
169
|
+
const entry = { type: "stdio", command: server.command, args: server.args };
|
|
170
|
+
if (scope !== "project") {
|
|
171
|
+
return {
|
|
172
|
+
name,
|
|
173
|
+
server,
|
|
174
|
+
steps: [
|
|
175
|
+
{ run: ["claude", "mcp", "remove", name, "--scope", scope], quiet: true, why: `drops an earlier ${name} server in that scope, if there is one` },
|
|
176
|
+
{ run: ["claude", "mcp", "add-json", name, JSON.stringify(entry), "--scope", scope] },
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const file = path.join(io.cwd, ".mcp.json");
|
|
181
|
+
return { name, server, steps: [fileStep(file, io.cwd, (before) => mergeMcpJson(before, name, entry, file))] };
|
|
182
|
+
}
|
|
183
|
+
if (client === "codex") {
|
|
184
|
+
const dir = flags.project ? path.join(io.cwd, ".codex") : io.env.CODEX_HOME || path.join(home, ".codex");
|
|
185
|
+
const file = path.join(dir, "config.toml");
|
|
186
|
+
const step = fileStep(file, flags.project ? io.cwd : null, (before) => {
|
|
187
|
+
try {
|
|
188
|
+
return setServerSection(before ?? "", name, codexSection(name, server, { approve: Boolean(flags.approve) })).text;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
throw new CliError(EXIT.config, `${file}: ${error.message}`);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
return { name, server, steps: [step] };
|
|
194
|
+
}
|
|
195
|
+
if (client === "cursor") {
|
|
196
|
+
const file = flags.global ? path.join(home, ".cursor", "mcp.json") : path.join(io.cwd, ".cursor", "mcp.json");
|
|
197
|
+
const entry = { type: "stdio", command: server.command, args: server.args };
|
|
198
|
+
return { name, server, steps: [fileStep(file, flags.global ? null : io.cwd, (before) => mergeMcpJson(before, name, entry, file))] };
|
|
199
|
+
}
|
|
200
|
+
throw new CliError(EXIT.usage, `capa init takes ${CLIENTS.join(", ")}${client ? `, not ${client}` : ""}.`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const START = "<!-- capa:start -->";
|
|
204
|
+
const END = "<!-- capa:end -->";
|
|
205
|
+
|
|
206
|
+
/** The section `--instructions` writes, between markers so a second run replaces it. */
|
|
207
|
+
export function instructionsSection(me, sees) {
|
|
208
|
+
const models = (Array.isArray(me?.models) ? me.models : []).map((m) => `\`${m.namespace}\`${Array.isArray(m.can) ? ` (${m.can.join(", ")})` : ""}`);
|
|
209
|
+
return [
|
|
210
|
+
START,
|
|
211
|
+
"## Capa content",
|
|
212
|
+
"",
|
|
213
|
+
`This project's content lives in Capa${me?.tenant?.name ? `, in the project "${me.tenant.name}"` : ""}. The \`capa\` MCP server reaches it with the key \`capa login\` stored; the \`capa\` command line takes the same key.`,
|
|
214
|
+
"",
|
|
215
|
+
models.length ? `Models this key reaches: ${models.join(", ")}.` : "Run `capa models` for the models this key reaches.",
|
|
216
|
+
"",
|
|
217
|
+
"- Before writing an entry, read the model: `capa_get_model { namespace }` names its fields.",
|
|
218
|
+
"- `capa_create_entry` and `capa_update_entry` save drafts. Read the result back with `capa_read_entries { model, id }`.",
|
|
219
|
+
"- Publish (`capa_publish_entry`) only when asked to. It changes what the site shows.",
|
|
220
|
+
sees ? `- ${sees}` : null,
|
|
221
|
+
"- From a shell: `capa entries list <model> --json`, `capa entries create <model> --data '{...}' --json`.",
|
|
222
|
+
END,
|
|
223
|
+
"",
|
|
224
|
+
]
|
|
225
|
+
.filter((line) => line !== null)
|
|
226
|
+
.join("\n");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** `text` with the capa section set to `section`: replaced between its markers, else appended. */
|
|
230
|
+
export function setInstructions(text, section) {
|
|
231
|
+
const body = text ?? "";
|
|
232
|
+
const start = body.indexOf(START);
|
|
233
|
+
const end = body.indexOf(END);
|
|
234
|
+
if (start !== -1 && end > start) return `${body.slice(0, start)}${section.replace(/\n$/, "")}${body.slice(end + END.length)}`;
|
|
235
|
+
const trimmed = body.replace(/\s*$/, "");
|
|
236
|
+
return `${trimmed}${trimmed ? "\n\n" : ""}${section}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The step that writes the instructions file: CLAUDE.md for Claude Code, AGENTS.md for Codex and Cursor. */
|
|
240
|
+
export function planInstructions(client, io, me, sees) {
|
|
241
|
+
const file = path.join(io.cwd, client === "claude" ? "CLAUDE.md" : "AGENTS.md");
|
|
242
|
+
return fileStep(file, io.cwd, (before) => setInstructions(before, instructionsSection(me, sees)));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Prints the plan. Answers whether there is anything to do. */
|
|
246
|
+
export function showPlan(out, steps) {
|
|
247
|
+
let changes = 0;
|
|
248
|
+
for (const step of steps) {
|
|
249
|
+
if (step.run) {
|
|
250
|
+
changes++;
|
|
251
|
+
out.note(`Will run: ${step.run.map((a) => (/^[\w./:@=-]+$/.test(a) ? a : `'${a.replace(/'/g, "'\\''")}'`)).join(" ")}${step.why ? ` (${step.why})` : ""}`);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const shown = step.target ? `${step.file} (a link to ${step.target})` : step.file;
|
|
255
|
+
if (step.before === step.after) {
|
|
256
|
+
out.note(`${shown}: already set up, no change.`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
changes++;
|
|
260
|
+
out.note(`${shown}${step.created ? " (new file)" : ""}:`);
|
|
261
|
+
out.note(lineDiff(step.before, step.after));
|
|
262
|
+
}
|
|
263
|
+
return changes > 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The file a write lands in: `file` itself, or, when it is a symlink (a
|
|
268
|
+
* dotfiles repo linking ~/.codex/config.toml), the file the link points at,
|
|
269
|
+
* so the link stays a link. A link to a file not made yet resolves too.
|
|
270
|
+
*/
|
|
271
|
+
function writeTarget(file) {
|
|
272
|
+
let target = file;
|
|
273
|
+
for (let hops = 0; hops < 32; hops++) {
|
|
274
|
+
let stat;
|
|
275
|
+
try {
|
|
276
|
+
stat = fs.lstatSync(target);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (error.code === "ENOENT") return target;
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
if (!stat.isSymbolicLink()) return target;
|
|
282
|
+
// Relative to the folder the link is in, as the disk has it (that folder may be a link too).
|
|
283
|
+
target = path.resolve(fs.realpathSync.native(path.dirname(target)), fs.readlinkSync(target));
|
|
284
|
+
}
|
|
285
|
+
throw new CliError(EXIT.config, `${file} is a symlink that loops, so it is left alone.`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Where a write to `file` lands: through its links, and through links among the folders on the way. */
|
|
289
|
+
function landsAt(file) {
|
|
290
|
+
const target = writeTarget(file);
|
|
291
|
+
let dir = path.dirname(target);
|
|
292
|
+
let rest = path.basename(target);
|
|
293
|
+
for (;;) {
|
|
294
|
+
try {
|
|
295
|
+
return path.join(fs.realpathSync.native(dir), rest);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (error.code !== "ENOENT") throw error;
|
|
298
|
+
const up = path.dirname(dir);
|
|
299
|
+
if (up === dir) return target;
|
|
300
|
+
rest = path.join(path.basename(dir), rest);
|
|
301
|
+
dir = up;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Is `inner` the folder `outer`, or inside it? */
|
|
307
|
+
const within = (inner, outer) => {
|
|
308
|
+
const relative = path.relative(outer, inner);
|
|
309
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The step that writes `file`, its new text made by `after(before)`. `target`
|
|
314
|
+
* is set when the file is a link, and the plan prints it. A file of the project (`project` is its folder)
|
|
315
|
+
* whose write lands outside the project is refused before it is read: a
|
|
316
|
+
* cloned repository can link .mcp.json to any file of the person's, which
|
|
317
|
+
* would be printed as a diff and then written.
|
|
318
|
+
*/
|
|
319
|
+
function fileStep(file, project, after) {
|
|
320
|
+
const lands = landsAt(file);
|
|
321
|
+
if (project && !within(lands, fs.realpathSync.native(project))) {
|
|
322
|
+
throw new CliError(
|
|
323
|
+
EXIT.config,
|
|
324
|
+
`${file} points outside this project, to ${lands}, so capa init leaves it alone: a link in a project can point at any of your files. Remove the link, or add the server to ${lands} yourself.`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
const before = readText(file);
|
|
328
|
+
const linked = writeTarget(file) !== file;
|
|
329
|
+
return { file, ...(linked ? { target: lands } : {}), before: before ?? "", after: after(before), created: before === null };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Writes each file (keeping an existing file's mode, and writing through a symlink) and runs each command. */
|
|
333
|
+
export function applyPlan(steps, io) {
|
|
334
|
+
const done = [];
|
|
335
|
+
for (const step of steps) {
|
|
336
|
+
if (step.run) {
|
|
337
|
+
const [command, ...args] = step.run;
|
|
338
|
+
const result = io.run(command, args);
|
|
339
|
+
if (result.error) {
|
|
340
|
+
if (step.quiet) continue;
|
|
341
|
+
throw new CliError(EXIT.config, `could not run ${command}: ${result.error.code === "ENOENT" ? `${command} is not on PATH` : result.error.message}.`);
|
|
342
|
+
}
|
|
343
|
+
if (result.status !== 0 && !step.quiet) {
|
|
344
|
+
throw new CliError(EXIT.refused, `${command} ${args.slice(0, 2).join(" ")} failed: ${(result.stderr || result.stdout).trim()}`);
|
|
345
|
+
}
|
|
346
|
+
done.push({ ran: step.run.slice(0, 3).join(" ") });
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (step.before === step.after) continue;
|
|
350
|
+
fs.mkdirSync(path.dirname(step.file), { recursive: true });
|
|
351
|
+
const file = writeTarget(step.file);
|
|
352
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
353
|
+
let mode = 0o644;
|
|
354
|
+
try {
|
|
355
|
+
mode = fs.statSync(file).mode & 0o777;
|
|
356
|
+
} catch {
|
|
357
|
+
// new file
|
|
358
|
+
}
|
|
359
|
+
const temp = `${file}.capa-${process.pid}.tmp`;
|
|
360
|
+
fs.writeFileSync(temp, step.after, { mode });
|
|
361
|
+
fs.renameSync(temp, file);
|
|
362
|
+
done.push({ wrote: step.file });
|
|
363
|
+
}
|
|
364
|
+
return done;
|
|
365
|
+
}
|