sign-off-axi 0.3.3 → 0.3.4
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 +4 -4
- package/dist/cli.js +42 -7
- package/dist/vault.js +61 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -137,8 +137,8 @@ sign-off-axi status is the board open started for th
|
|
|
137
137
|
sign-off-axi stop stop that board
|
|
138
138
|
sign-off-axi serve [--port 4410] [--bind <address>] [--evidence-root <dir>] run the board in the foreground (--port 0 picks a free port)
|
|
139
139
|
sign-off-axi answers [--key <key>] the latest answer per decision, and how many still wait
|
|
140
|
-
sign-off-axi wait [--filer <id>] [--key <key>] [--timeout 600] block until the user answers, then print it like answers
|
|
141
|
-
(--filer: only that filer's items; without --key each answer once)
|
|
140
|
+
sign-off-axi wait [--filer <id>] [--key <key>] [--timeout 600] [--events (needs --filer)] block until the user answers, then print it like answers; --events prints one JSON event line per answer and round (docs/contract.md section 7)
|
|
141
|
+
(--filer: only that filer's items, each marked taken; without --key each answer once)
|
|
142
142
|
sign-off-axi poll [--timeout 600] block until the user comments or queues a prompt, then print each new one once
|
|
143
143
|
sign-off-axi export [--out <file.html>] write a frozen read-only copy of the board as one HTML file
|
|
144
144
|
```
|
|
@@ -159,8 +159,8 @@ sign-off-axi export [--out <file.html>] write a frozen read-only copy o
|
|
|
159
159
|
- Raising a question that is already on the board is a no-op that reports its status, so an agent can re-run it safely. `--key` defaults to a slug of the title.
|
|
160
160
|
- Each raised or posted item records when it was filed (`filed`), so Triage shows `Waiting since 09:15` and the Waiting on you tile `Oldest 09:15`; `post --replace` keeps an item's first time.
|
|
161
161
|
- `open` is a no-op when the same board is already open on the same address, and fails with `CONFLICT` when it runs on another one. `--evidence-root` is the folder holding `.done-done/`, for decisions that link run evidence.
|
|
162
|
-
- `--filer <id>` on `post` and `raise` saves who filed the item (an agent session id or any 1-128 of `A-Z a-z 0-9 . _ -`; `SIGN_OFF_FILER` sets it for every command, and a post with neither saves `CLAUDE_CODE_SESSION_ID` when it is set). `wait --filer <id>` then returns only answers to that filer's items it has not taken yet, marks each taken (`decisions.seen.jsonl`) and the card shows **Seen by agent**; it fails with `NOT_FOUND` when none of that filer's items still needs an answer. The page never shows the filer id.
|
|
163
|
-
- `on_answer: <command> [args]` in the `config.yaml` beside the board runs that command for every saved answer, read when the board starts. It runs with no shell, in the board's folder, and gets the answer as one JSON line on stdin (`{"key","answer","label","close","at","note"?,"title"?,"filer"?,"comments"?}`, where `comments`, present when the answer carried comments on mock elements, lists each as `id`, `mock`, `hash`, `ref`, `tag`, `words` and `text`); the answer is never put into its arguments, so a typed note cannot inject a command. A failure is logged and never blocks the answer. With no shell, `|`, `>` and `&&` are passed as plain arguments, so put a pipeline in a script and name the script; a relative path such as `./hook.sh` is found from the board's folder. `doctor` checks that the command can run.
|
|
162
|
+
- `--filer <id>` on `post` and `raise` saves who filed the item (an agent session id or any 1-128 of `A-Z a-z 0-9 . _ -`; `SIGN_OFF_FILER` sets it for every command, and a post with neither saves `CLAUDE_CODE_SESSION_ID` when it is set). `wait --filer <id>` then returns only answers to that filer's items it has not taken yet (with `--key`, that key's answer even if taken), marks each taken (`decisions.seen.jsonl`) and the card shows **Seen by agent**; it fails with `NOT_FOUND` when none of that filer's items still needs an answer. The page never shows the filer id.
|
|
163
|
+
- `on_answer: <command> [args]` in the `config.yaml` beside the board runs that command for every saved answer, read when the board starts. It runs with no shell, in the board's folder, and gets the answer as one JSON line on stdin (`{"event":"answer","contract":1,"kind","schema","key","answer","label","close","at","note"?,"title"?,"filer"?,"comments"?}`, where `kind` and `schema` are the item's contract kind, or `ask` and 1 for a board-format item, and `comments`, present when the answer carried comments on mock elements, lists each as `id`, `mock`, `hash`, `ref`, `tag`, `words` and `text`); the answer is never put into its arguments, so a typed note cannot inject a command. A failure is logged and never blocks the answer. With no shell, `|`, `>` and `&&` are passed as plain arguments, so put a pipeline in a script and name the script; a relative path such as `./hook.sh` is found from the board's folder. `doctor` checks that the command can run.
|
|
164
164
|
- `post --from <file> --replace --filer <id>` lets several programs feed one board: it replaces only that filer's items (and items with no filer that the file names by key), keeps every other item, the board's other top-level fields and all answers, and an empty items list removes that filer's items.
|
|
165
165
|
- `wait` replaces a polling loop. With `--key` it returns at once if that decision is already answered, and waits for a key that is not raised yet; without it, it waits for the next answer to any decision that still needs an answer and fails with `NOT_FOUND` when nothing needs one. It wakes on each save, gives up after `--timeout` seconds (600, ten minutes, by default) with code `TIMEOUT` and exit 1, and takes `--full` and `--json` like `answers`. Ctrl-C stops it without changing anything.
|
|
166
166
|
- `open` records the board's pid, port and address next to the board file (`decisions.board.pid`). `status` and `stop` read it and only act on a pid that really serves that board, so no one has to guess a pid to kill. `open` on a board already served by `serve` records that server, so `stop` can close it too.
|
package/dist/cli.js
CHANGED
|
@@ -29,7 +29,7 @@ const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
|
|
|
29
29
|
commands[15]:
|
|
30
30
|
(none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, doctor, validate, help
|
|
31
31
|
board: the nearest .sign-off/ board (the walk's bounds and SIGN_OFF_HOME: README, "Test runs never touch your board"), else ~/.sign-off/decisions.json; a board file given always wins
|
|
32
|
-
flags: --full and --json on list, answers and wait, --json on status, --filer on post, raise and wait, --help on every command, -v/--version
|
|
32
|
+
flags: --full and --json on list, answers and wait, --events on wait, --json on status, --filer on post, raise and wait, --help on every command, -v/--version
|
|
33
33
|
examples:
|
|
34
34
|
sign-off-axi
|
|
35
35
|
sign-off-axi raise --title "Ship the new sign-in screen this week?" --recommend yes
|
|
@@ -106,7 +106,7 @@ examples:
|
|
|
106
106
|
sign-off-axi answers --key keep-the-weekly-digest
|
|
107
107
|
sign-off-axi answers ./decisions.json --json
|
|
108
108
|
`,
|
|
109
|
-
wait: `usage: sign-off-axi wait [board.json] [--filer <id>] [--key <key>] [--timeout 600] [--full] [--json]
|
|
109
|
+
wait: `usage: sign-off-axi wait [board.json] [--filer <id>] [--key <key>] [--timeout 600] [--full] [--json | --events]
|
|
110
110
|
Blocks until the user answers, then prints the answer the way answers does. Use it instead of a polling loop.
|
|
111
111
|
--filer only the items posted with this --filer (or SIGN_OFF_FILER): returns every answer to them this filer has not taken yet,
|
|
112
112
|
even one saved before wait started, and marks it taken, so the board shows "Seen by agent" on that card;
|
|
@@ -117,11 +117,15 @@ Blocks until the user answers, then prints the answer the way answers does. Use
|
|
|
117
117
|
--timeout seconds to wait, 600 (10 minutes) by default; then exits 1 with code TIMEOUT
|
|
118
118
|
--full adds the button label, close and title
|
|
119
119
|
--json {"<key>":{"answer","label","close","at","note"?}} for the new answers; note only when the user wrote one
|
|
120
|
+
--events needs --filer (or SIGN_OFF_FILER); one JSON line per new answer and per another-round ask, in the order they were saved (${CONTRACT}#7-answers):
|
|
121
|
+
{"event":"answer","contract":1,"kind","schema","key","title","answer","label","close","at","note"?,"filer"?,"comments"?}
|
|
122
|
+
{"event":"round","contract":1,"kind","schema","key","title","note","at","filer"?,"comments"?}; a board-format item reports as ask schema 1
|
|
120
123
|
A note column appears when any shown answer has a note.
|
|
121
124
|
Ctrl-C stops waiting; nothing is changed.
|
|
122
125
|
examples:
|
|
123
126
|
sign-off-axi wait --key keep-the-weekly-digest
|
|
124
127
|
sign-off-axi wait ./decisions.json --timeout 60 --json
|
|
128
|
+
sign-off-axi wait --filer agent-41 --events
|
|
125
129
|
`,
|
|
126
130
|
open: `usage: sign-off-axi open [board.json] [--port 4410] [--bind <address>] [--evidence-root <dir>]
|
|
127
131
|
Starts the board in the background on 127.0.0.1 and prints its URL and pid; a no-op when this board is already open.
|
|
@@ -442,11 +446,32 @@ function answers(args) {
|
|
|
442
446
|
const waiting = rows.filter(waits).length, key = values.key === undefined ? '' : ` --key ${values.key}`;
|
|
443
447
|
return printAnswers(file, rows, waiting, values, waiting ? [`Run \`sign-off-axi open${on}\` so the user can answer the ${waiting} waiting`, `Run \`sign-off-axi wait${on}${key}\` to block until they answer`] : []);
|
|
444
448
|
}
|
|
449
|
+
/** The contract fields an answer or round reports with: the item's kind and schema, or ask schema 1 for a board-format or dropped item (contract section 2). */
|
|
450
|
+
function kindOf(file) {
|
|
451
|
+
const items = new Map((existsSync(file) ? readItems(file).items : []).map((i) => [i?.key, i]));
|
|
452
|
+
return (key) => {
|
|
453
|
+
const i = items.get(key), contract = typeof i?.kind === 'string';
|
|
454
|
+
return { contract: 1, kind: contract ? i.kind : 'ask', schema: contract ? i.schema : 1 };
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
/** One JSON line per answer and per round (contract section 7), oldest first. */
|
|
458
|
+
function eventLines(file, rows, answers, rounds) {
|
|
459
|
+
const kind = kindOf(file), item = new Map(rows.map((d) => [d.key, d]));
|
|
460
|
+
const head = (event, key) => ({ event, ...kind(key), key, title: item.get(key)?.title });
|
|
461
|
+
const tail = (key, comments) => ({ ...(item.get(key)?.filer && { filer: item.get(key).filer }), ...(comments?.length && { comments }) });
|
|
462
|
+
return [...answers.map(({ key, answer, label, close, at, note, comments }) => ({ at, line: { ...head('answer', key), answer, label, close, at, ...(note && { note }), ...tail(key, comments) } })),
|
|
463
|
+
...rounds.map(({ key, note, at, comments }) => ({ at, line: { ...head('round', key), note, at, ...tail(key, comments) } }))]
|
|
464
|
+
.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)).map(({ line }) => JSON.stringify(line)).join('\n');
|
|
465
|
+
}
|
|
445
466
|
async function wait(args) {
|
|
446
|
-
const { file, values, on: onBoard } = parse('wait', args, { ...TIERS, filer: { type: 'string' }, key: { type: 'string' }, timeout: { type: 'string', default: '600' }, into: { type: 'string' } });
|
|
467
|
+
const { file, values, on: onBoard } = parse('wait', args, { ...TIERS, events: { type: 'boolean' }, filer: { type: 'string' }, key: { type: 'string' }, timeout: { type: 'string', default: '600' }, into: { type: 'string' } });
|
|
447
468
|
if (!/^\d{1,7}$/.test(values.timeout))
|
|
448
469
|
throw fail('--timeout must be whole seconds, 0 or more', 'VALIDATION_ERROR', `Run \`sign-off-axi wait --help\``);
|
|
470
|
+
if (values.events && (values.json || values.full))
|
|
471
|
+
throw fail(`--events prints event lines, so it does not take --${values.json ? 'json' : 'full'}`, 'VALIDATION_ERROR', 'Run `sign-off-axi wait --filer <id> --events`');
|
|
449
472
|
const filer = filerOf(values), on = onBoard + (filer ? ` --filer ${filer}` : '');
|
|
473
|
+
if (values.events && filer === undefined)
|
|
474
|
+
throw fail(`--events prints a filer's answer events, so it needs --filer`, 'VALIDATION_ERROR', 'Run `sign-off-axi wait --filer <id> --events`');
|
|
450
475
|
// Vault Give delivery: its own loop, before any board read, so no ordinary wait changes. Undocumented while the vault is OFF.
|
|
451
476
|
if (values.into !== undefined)
|
|
452
477
|
return waitInto(file, values, onBoard);
|
|
@@ -471,22 +496,32 @@ async function wait(args) {
|
|
|
471
496
|
for (let first = true;; first = false) {
|
|
472
497
|
const rows = mine(board(file, first ? warn : () => { })), waiting = rows.filter(waits).length, taken = filer === undefined ? undefined : seenBy(file);
|
|
473
498
|
const hit = rows.filter((d) => (values.key !== undefined ? d.key === values.key : d.answer && (taken ? !taken(d.answer) : !before.has(d.key))));
|
|
474
|
-
const counts = new Map();
|
|
475
|
-
for (const r of readRounds(file, first ? warn : () => { }))
|
|
499
|
+
const counts = new Map(), asked = [];
|
|
500
|
+
for (const r of readRounds(file, first ? warn : () => { })) {
|
|
476
501
|
counts.set(r.key, (counts.get(r.key) ?? 0) + 1);
|
|
502
|
+
if (counts.get(r.key) > (seenRounds.get(r.key) ?? 0))
|
|
503
|
+
asked.push(r);
|
|
504
|
+
}
|
|
477
505
|
const fresh = rows.filter((d) => (counts.get(d.key) ?? 0) > (seenRounds.get(d.key) ?? 0)
|
|
478
506
|
&& (values.key === undefined || d.key === values.key));
|
|
479
507
|
for (const [k, n] of counts)
|
|
480
508
|
seenRounds.set(k, n);
|
|
481
509
|
let found = [...new Map([...hit.filter((d) => d.answer), ...fresh].map((d) => [d.key, d])).values()];
|
|
510
|
+
// The answers this wait hands over: a filer wait without --key gives only the ones it has not taken yet.
|
|
511
|
+
let given = hit.flatMap((d) => (d.answer ? [d.answer] : []));
|
|
482
512
|
if (found.length && filer !== undefined) {
|
|
483
513
|
// Taken under a lock, so two waits for one filer never both hand over the same answer (another-round asks are not deduped here).
|
|
484
514
|
found = await locked(`${seenPath(file)}.lock`, () => {
|
|
485
515
|
const now = seenBy(file), left = found.filter((d) => values.key !== undefined || fresh.includes(d) || !now(d.answer));
|
|
486
|
-
|
|
516
|
+
const taking = left.flatMap((d) => (d.answer && !now(d.answer) ? [d.answer] : []));
|
|
517
|
+
markSeen(file, taking);
|
|
518
|
+
if (values.key === undefined)
|
|
519
|
+
given = taking;
|
|
487
520
|
return left;
|
|
488
521
|
}, 'another wait is still taking answers');
|
|
489
522
|
}
|
|
523
|
+
if (found.length && values.events)
|
|
524
|
+
return eventLines(file, found, given, asked.filter((r) => fresh.some((d) => d.key === r.key)));
|
|
490
525
|
if (found.length) {
|
|
491
526
|
return printAnswers(file, found, waiting, values, [waiting ? `Run \`sign-off-axi wait${on}\` for the next of the ${waiting} waiting` : `Run \`sign-off-axi stop${onBoard}\` to close the board`]);
|
|
492
527
|
}
|
|
@@ -1895,7 +1930,7 @@ function answered(a, d) {
|
|
|
1895
1930
|
child.on('error', (e) => warn(`on_answer ${cmd} did not run: ${e.message}`));
|
|
1896
1931
|
child.on('exit', (code, signal) => (code || signal) && warn(`on_answer ${cmd} exited with ${code ?? signal}`));
|
|
1897
1932
|
child.stdin.on('error', () => { });
|
|
1898
|
-
child.stdin.end(`${JSON.stringify({ ...a, ...(d && { title: d.title }), ...(d?.filer && { filer: d.filer }) })}\n`);
|
|
1933
|
+
child.stdin.end(`${JSON.stringify({ event: 'answer', ...kindOf(file)(a.key), ...a, ...(d && { title: d.title }), ...(d?.filer && { filer: d.filer }) })}\n`);
|
|
1899
1934
|
}
|
|
1900
1935
|
const server = createServer(async (req, res) => {
|
|
1901
1936
|
const { port: bound } = server.address();
|
package/dist/vault.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, isAbsolute, join, parse, resolve } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
+
import { isIP } from 'node:net';
|
|
5
6
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
6
7
|
import { isKey } from './store.js';
|
|
7
8
|
export const errors = {
|
|
@@ -27,7 +28,7 @@ export class VaultError extends Error {
|
|
|
27
28
|
}
|
|
28
29
|
function bad(code) { throw new VaultError(code); }
|
|
29
30
|
const digest = (board, key) => createHash('sha256').update(JSON.stringify([board, key])).digest('hex');
|
|
30
|
-
const aad = (meta) => Buffer.from(JSON.stringify([1, meta.board, meta.key, meta.mode, meta.expires, meta.destination ?? null, meta.filer]));
|
|
31
|
+
const aad = (meta) => Buffer.from(JSON.stringify([1, meta.board, meta.key, meta.mode, meta.expires, meta.destination ?? null, meta.filer, ...(meta.provider === undefined ? [] : [meta.provider])]));
|
|
31
32
|
const VAULT_ID = /^[a-f0-9]{32}$/;
|
|
32
33
|
function boardFile(board) {
|
|
33
34
|
if (typeof board !== 'string' || !isAbsolute(board))
|
|
@@ -68,10 +69,10 @@ const plain = (p) => isAbsolute(p) && !p.split('/').some((s) => s === '.' || s =
|
|
|
68
69
|
function metadata(m) {
|
|
69
70
|
if (!m || typeof m.board !== 'string' || !VAULT_ID.test(m.board) || !isKey(m.key) || !isKey(m.filer) || !['give', 'receive'].includes(m.mode)
|
|
70
71
|
|| !Number.isSafeInteger(m.expires) || m.expires <= 0
|
|
71
|
-
|| (m.mode === 'give' ? typeof m.destination !== 'string' || !plain(m.destination) : m.destination !== undefined))
|
|
72
|
+
|| (m.mode === 'give' ? typeof m.destination !== 'string' || !plain(m.destination) || m.provider !== undefined : m.destination !== undefined || !isKey(m.provider)))
|
|
72
73
|
bad('VALIDATION_ERROR');
|
|
73
74
|
return { board: m.board, key: m.key, mode: m.mode, filer: m.filer, expires: m.expires,
|
|
74
|
-
...(m.destination !== undefined && { destination: resolve(m.destination) }) };
|
|
75
|
+
...(m.destination !== undefined && { destination: resolve(m.destination) }), ...(m.provider !== undefined && { provider: m.provider }) };
|
|
75
76
|
}
|
|
76
77
|
function privateFile(path) {
|
|
77
78
|
const s = lstatSync(path);
|
|
@@ -138,6 +139,49 @@ function approvedDirs(file, dir) {
|
|
|
138
139
|
return line;
|
|
139
140
|
});
|
|
140
141
|
}
|
|
142
|
+
// Owner-only list of approved Receive sites: `<provider> <https-origin>` per line, `#` comments. Any bad line denies all.
|
|
143
|
+
// Host rules live here; a link must then match one listed origin exactly, which carries them over.
|
|
144
|
+
function approvedProviders() {
|
|
145
|
+
const list = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'sign-off', 'vault-providers'), sites = new Map();
|
|
146
|
+
let text = '';
|
|
147
|
+
try {
|
|
148
|
+
if (isAbsolute(list)) {
|
|
149
|
+
privateFile(list);
|
|
150
|
+
text = readFileSync(list, 'utf8');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch { /* denied below */ }
|
|
154
|
+
if (!text)
|
|
155
|
+
bad('VALIDATION_ERROR');
|
|
156
|
+
for (const line of text.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))) {
|
|
157
|
+
const [name, origin, extra] = line.split(/\s+/);
|
|
158
|
+
let host = '';
|
|
159
|
+
try {
|
|
160
|
+
const u = new URL(origin);
|
|
161
|
+
if (u.protocol === 'https:' && u.origin === origin && !u.port)
|
|
162
|
+
host = u.hostname;
|
|
163
|
+
}
|
|
164
|
+
catch { /* denied below */ }
|
|
165
|
+
if (extra !== undefined || !isKey(name) || sites.has(name) || !host.includes('.') || host.endsWith('.') || isIP(host))
|
|
166
|
+
bad('VALIDATION_ERROR');
|
|
167
|
+
sites.set(name, origin);
|
|
168
|
+
}
|
|
169
|
+
return sites;
|
|
170
|
+
}
|
|
171
|
+
/** A Receive link: one https URL whose exact origin the owner listed for its provider. Fails only with VALIDATION_ERROR, never echoing it. */
|
|
172
|
+
export function checkReceiveUrl(value, provider) {
|
|
173
|
+
const origin = approvedProviders().get(provider);
|
|
174
|
+
let url;
|
|
175
|
+
try {
|
|
176
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(value).replace(/\r?\n$/, '');
|
|
177
|
+
// The URL parser silently drops tabs and newlines and trims spaces, so any space or control is refused before it.
|
|
178
|
+
if (!/[\x00-\x20\x7f-\x9f]/.test(text) && Buffer.byteLength(text) <= 8192)
|
|
179
|
+
url = new URL(text);
|
|
180
|
+
}
|
|
181
|
+
catch { /* refused below */ }
|
|
182
|
+
if (!origin || !url || url.username || url.password || url.origin !== origin)
|
|
183
|
+
bad('VALIDATION_ERROR');
|
|
184
|
+
}
|
|
141
185
|
function checkDestination(dest, file, dir) {
|
|
142
186
|
if (!approvedDirs(file, dir).includes(dirname(dest)))
|
|
143
187
|
bad('DESTINATION_DENIED');
|
|
@@ -489,6 +533,9 @@ export async function createVault(input, value) {
|
|
|
489
533
|
bad('VALIDATION_ERROR');
|
|
490
534
|
if ((fixed.mode === 'receive') !== (value !== undefined) || (value !== undefined && !sized(value)))
|
|
491
535
|
bad('VALIDATION_ERROR');
|
|
536
|
+
// Before custody: a refused link leaves no folder, folder id, key or record.
|
|
537
|
+
if (fixed.mode === 'receive')
|
|
538
|
+
checkReceiveUrl(value, fixed.provider);
|
|
492
539
|
return operate(input.board, fixed.key, true, (dir, path, old, id) => {
|
|
493
540
|
const meta = { ...fixed, board: id };
|
|
494
541
|
if (old)
|
|
@@ -554,17 +601,21 @@ export async function deliverVault(board, key, filer, into, afterClaim) {
|
|
|
554
601
|
try {
|
|
555
602
|
afterClaim?.();
|
|
556
603
|
const value = unseal(secret, r.meta, r.sealed);
|
|
557
|
-
const fd = openSync(dest, 'wx', 0o600);
|
|
558
604
|
try {
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
605
|
+
const fd = openSync(dest, 'wx', 0o600);
|
|
606
|
+
try {
|
|
607
|
+
const st = fstatSync(fd);
|
|
608
|
+
made = { dev: st.dev, ino: st.ino };
|
|
609
|
+
writeFileSync(fd, value);
|
|
610
|
+
fsyncSync(fd);
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
closeSync(fd);
|
|
614
|
+
}
|
|
563
615
|
}
|
|
564
616
|
finally {
|
|
565
|
-
closeSync(fd);
|
|
566
617
|
value.fill(0);
|
|
567
|
-
}
|
|
618
|
+
} // also when openSync or closeSync throws
|
|
568
619
|
syncDir(dirname(dest));
|
|
569
620
|
const done = { schema: 1, meta: r.meta, status: 'delivered', at: Date.now() };
|
|
570
621
|
publish(path, JSON.stringify(done));
|