@xeplr/actions 2.2.3 → 2.2.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 +20 -5
- package/lib/builtins/file/archive.js +151 -0
- package/lib/builtins/index.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -138,7 +138,7 @@ Register the ones you want (`register(actions.builtins.dbFetch)`) or pass a modu
|
|
|
138
138
|
| `dbFetch` | `db-fetch` | Stream rows from a table (`mode: 'table'`) or SQL (`mode: 'query'`, `params`). Default `streaming_mode: true` → NDJSON file `{ filePath, format: 'jsonl', bytes, rows }`; `false` → `{ rows, rowCount }`. |
|
|
139
139
|
| `dbPush` | `db-push` | Write `rows` or an NDJSON `filePath` into `targetTable` through the uploader. `primaryKeys` → upsert. |
|
|
140
140
|
| `dbMove` | `db-move` | One window of rows from a source table, query or procedure into a target table in one streamed pass. See [db-move](#db-move). |
|
|
141
|
-
| `dbDump` | `db-dump` | A whole database to one backup file with its own dump program: `pg_dump -Fc` (Postgres, `.dump`) or `mysqldump` (MySQL, `.sql.gz`). See [Backups](#backups-db-dump-and-s3-upload). |
|
|
141
|
+
| `dbDump` | `db-dump` | A whole database to one backup file with its own dump program: `pg_dump -Fc` (Postgres, `.dump`) or `mysqldump` (MySQL, `.sql.gz`). See [Backups](#backups-db-dump-folder-archive-and-s3-upload). |
|
|
142
142
|
| `dbProcedure` | `db-procedure` | Call a stored procedure; returns `{ rows, rowCount, procedure, sql }`. See [db-procedure](#db-procedure). |
|
|
143
143
|
| `dbListTables` | `db-list-tables` | `{ tables }` — base tables in a schema/database. |
|
|
144
144
|
| `dbListViews` | `db-list-views` | `{ views }`. |
|
|
@@ -153,7 +153,8 @@ Register the ones you want (`register(actions.builtins.dbFetch)`) or pass a modu
|
|
|
153
153
|
| `emailDownloadEmail` | `email-download-email` | Stream the raw message to an `.eml` file, named by `fileName`. `{ count, saved[], path, existing[] }`. |
|
|
154
154
|
| `emailDownloadAttachments` | `email-download-attachments` | Save attachments, optionally filtered by `extension` / `filenameContains`, named by `fileName` (see [Saving files](#saving-files-names-and-a-name-already-taken)). `{ count, saved[], path, skipped, existing[] }`. |
|
|
155
155
|
| `fileList` | `file-list` | The files in a folder, optionally matching `pattern` (`*.csv`, `Bank-2026-??.pdf`), sorted by name or date. `{ files[{ name, path, size, modifiedAt }], count }`. "Is it there?" is the exact name and `count`. See [Where files are](#where-files-are-one-data-root-a-folder-per-tenant). |
|
|
156
|
-
| `s3Upload` | `s3-upload` | One file to an S3 bucket (or an S3-compatible `endpoint`), in parts, with a SHA-256 checksum. Only writes. See [Backups](#backups-db-dump-and-s3-upload). |
|
|
156
|
+
| `s3Upload` | `s3-upload` | One file to an S3 bucket (or an S3-compatible `endpoint`), in parts, with a SHA-256 checksum. Only writes. See [Backups](#backups-db-dump-folder-archive-and-s3-upload). |
|
|
157
|
+
| `folderArchive` | `folder-archive` | A folder of the tenant's own data folder to one `.tar.gz` (`tar`, no shell); its `backups/` and `tmp/` always left out. See [Backups](#backups-db-dump-folder-archive-and-s3-upload). |
|
|
157
158
|
| `fileRead` | `file-read` | A CSV, text, Excel, JSON or NDJSON file as rows (format from the extension). `{ rows, count, columns, truncated }`, at most `maxRows` (50,000). |
|
|
158
159
|
| `fileMove` | `file-move` | Move or copy a file into a folder (made if missing) or to a new name; `ifExists` as every saving step. `{ path, name, moved, skipped }`. |
|
|
159
160
|
| `fileDelete` | `file-delete` | Delete one file, **permanently** (no trash; `file-move` to a "processed" folder is the reversible one). Never a folder. `ifMissing`: `fail` (default) or `skip`. `{ path, deleted, skipped }`. |
|
|
@@ -208,9 +209,9 @@ For `mode: 'table'` the source's declared column types (`getTableSchema`) are pa
|
|
|
208
209
|
|
|
209
210
|
Message ids are IMAP UIDs **scoped to `folder`**, and a move changes the id — download and delete before moving.
|
|
210
211
|
|
|
211
|
-
## Backups: `db-dump` and `s3-upload`
|
|
212
|
+
## Backups: `db-dump`, `folder-archive` and `s3-upload`
|
|
212
213
|
|
|
213
|
-
A
|
|
214
|
+
A backup is two steps: make one file — a database's dump, or a folder's archive — then send the file away.
|
|
214
215
|
|
|
215
216
|
```js
|
|
216
217
|
var dumped = await runAction({ name: 'db-dump', input: {
|
|
@@ -242,6 +243,20 @@ await runAction({ name: 's3-upload', input: {
|
|
|
242
243
|
- **Written under `<name>.partial`, renamed only when the program ends cleanly.** A failure removes it and fails the step with the end of what the program said, the password taken out. A program that is not installed is named ("pg_dump is not installed on this server").
|
|
243
244
|
- `system.signal` stops it (the program is killed); `system.onProgress({ bytes })` every second.
|
|
244
245
|
|
|
246
|
+
**folder-archive**
|
|
247
|
+
|
|
248
|
+
| input | |
|
|
249
|
+
|---|---|
|
|
250
|
+
| `folder` | A folder in the tenant's own folder, checked like every file step's (outside, `../`, a link pointing out: refused). Blank or `.`: all of it. |
|
|
251
|
+
| `exclude` | Patterns left out, comma separated (`node_modules, *.log`) — tar's `--exclude`. One starting with `-` is refused. |
|
|
252
|
+
| `fileName` | Default `<folder>-<UTC time>.tar.gz` (`all-…` for the whole folder), in the tenant's `backups/`. A name, not a path. |
|
|
253
|
+
|
|
254
|
+
→ `{ filePath, fileName, bytes, sha256, folder, warning }`
|
|
255
|
+
|
|
256
|
+
- **An app backs up only its own files.** The folder is inside the tenant's folder like every file step's, so an app (or the monitor) embedding this can never archive anything else on the server.
|
|
257
|
+
- **Never its own `backups/` and `tmp/`:** the whole folder is archived entry by entry without them, and naming either (or a folder in them) is refused — a backup must not hold the backups before it.
|
|
258
|
+
- `tar -czf` with an argument list, no shell; written as `<name>.partial`, renamed when tar ends. Exit 1 — GNU tar's "a file changed while it was read", the archive still whole — succeeds with `warning`; anything else removes the file and fails with the end of what tar said. `system.signal` kills tar.
|
|
259
|
+
|
|
245
260
|
**s3-upload**
|
|
246
261
|
|
|
247
262
|
| input | |
|
|
@@ -260,7 +275,7 @@ await runAction({ name: 's3-upload', input: {
|
|
|
260
275
|
```
|
|
261
276
|
- Peers, optional: `@aws-sdk/client-s3` and `@aws-sdk/lib-storage` (`^3`). Missing → the step says to install them.
|
|
262
277
|
|
|
263
|
-
`test/backup/` checks
|
|
278
|
+
`test/backup/` checks all three: `folder-archive` with a real `tar` (patterns left out, `backups/` and `tmp/` never in it, outside paths and links refused); the dump programs with a stand-in on `PATH` (arguments, password only in the environment, a failure leaving no file), a real `pg_dump` restored with `pg_restore --list` when Postgres is here, and the upload against an S3 stand-in (one PUT, then parts, a SHA-256 checksum, never a read).
|
|
264
279
|
|
|
265
280
|
## Data operation
|
|
266
281
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// folder-archive action — a folder of this company's data folder, packed into
|
|
2
|
+
// one .tar.gz backup file (then s3-upload sends it away).
|
|
3
|
+
//
|
|
4
|
+
// folder inside the tenant's own folder (lib/dataDir.js), checked like
|
|
5
|
+
// every file step's: a path outside, ../ or a link pointing out is
|
|
6
|
+
// refused. '' or '.' is the whole tenant folder
|
|
7
|
+
// never the tenant's own backups/ and tmp/ — a backup must not hold the
|
|
8
|
+
// backups before it, nor half-written spools. Archiving the whole
|
|
9
|
+
// folder names every other entry; naming backups/ or tmp/ (or a
|
|
10
|
+
// folder inside them) is refused
|
|
11
|
+
// exclude patterns left out ("node_modules, *.log"), tar's --exclude; none
|
|
12
|
+
// may start with "-", which tar would read as an option
|
|
13
|
+
//
|
|
14
|
+
// `tar -czf` is RUN WITH AN ARGUMENT LIST, never a shell. The file is written
|
|
15
|
+
// as <name>.partial and renamed only once tar ends: exit 0, or 1 — GNU tar's
|
|
16
|
+
// "a file changed while it was read" (a log being written), where the archive
|
|
17
|
+
// is whole — which comes back as `warning`. Anything else removes the file and
|
|
18
|
+
// fails the step with the end of what tar said. A stop (system.signal) kills
|
|
19
|
+
// tar. Written to the tenant's backups/: <slug>-<UTC time>.tar.gz.
|
|
20
|
+
|
|
21
|
+
var fs = require('fs');
|
|
22
|
+
var path = require('path');
|
|
23
|
+
var crypto = require('crypto');
|
|
24
|
+
var { spawn } = require('child_process');
|
|
25
|
+
var dataDir = require('../../dataDir');
|
|
26
|
+
|
|
27
|
+
var OUT_FOLDER = 'backups';
|
|
28
|
+
var NEVER = ['backups', 'tmp'];
|
|
29
|
+
var STDERR_KEEP = 2048;
|
|
30
|
+
|
|
31
|
+
function refuse(message, code) {
|
|
32
|
+
var err = new Error(message);
|
|
33
|
+
err.code = code;
|
|
34
|
+
return err;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stamp(date) {
|
|
38
|
+
return date.toISOString().slice(0, 19).replace(/:/g, '-');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** "uploads/2026" → "uploads-2026"; the whole folder → "all". */
|
|
42
|
+
function slugOf(rel) {
|
|
43
|
+
return String(rel).replace(/^[./]+|\/+$/g, '').replace(/[^A-Za-z0-9._-]+/g, '-') || 'all';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** "node_modules, *.log" → the patterns; one starting with "-" is refused. */
|
|
47
|
+
function excludesOf(text) {
|
|
48
|
+
var list = (Array.isArray(text) ? text : String(text || '').split(/[\n,]/)).map(function(p) { return String(p).trim(); }).filter(Boolean);
|
|
49
|
+
list.forEach(function(p) {
|
|
50
|
+
if (p[0] === '-') throw refuse('folder-archive: the pattern "' + p + '" cannot start with "-".', 'BAD_ARGUMENT');
|
|
51
|
+
});
|
|
52
|
+
return list;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What tar is asked to pack: from the tenant folder, its entries except backups/ and tmp/; else the folder itself. */
|
|
56
|
+
function targetOf(input) {
|
|
57
|
+
var root = fs.realpathSync(dataDir.tenantFolder());
|
|
58
|
+
var asked = String(input.folder === undefined || input.folder === null ? '' : input.folder).trim();
|
|
59
|
+
var dir = asked === '' || asked === '.' ? root : dataDir.resolve(asked, 'The folder');
|
|
60
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) throw refuse('"' + asked + '" is not a folder that exists.', 'NOT_A_FOLDER');
|
|
61
|
+
var rel = path.relative(root, dir);
|
|
62
|
+
var top = rel.split(path.sep)[0];
|
|
63
|
+
if (rel && NEVER.indexOf(top) >= 0) {
|
|
64
|
+
throw refuse('folder-archive: ' + top + '/ holds the backups and temporary files themselves — name a folder beside it.', 'PATH_NOT_ALLOWED');
|
|
65
|
+
}
|
|
66
|
+
if (!rel) {
|
|
67
|
+
var entries = fs.readdirSync(root).filter(function(n) { return NEVER.indexOf(n) < 0; }).sort();
|
|
68
|
+
if (!entries.length) throw refuse('folder-archive: there is nothing in this company\'s folder to back up.', 'NOTHING_TO_ARCHIVE');
|
|
69
|
+
return { dir: root, rel: '', cwd: root, members: entries };
|
|
70
|
+
}
|
|
71
|
+
return { dir: dir, rel: rel, cwd: path.dirname(dir), members: [path.basename(dir)] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
name: 'folder-archive',
|
|
76
|
+
example: { filePath: '/data/app/backups/uploads-2026-11-13T02-00-05.tar.gz', fileName: 'uploads-2026-11-13T02-00-05.tar.gz', bytes: 10485760,
|
|
77
|
+
sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', folder: 'uploads', warning: null },
|
|
78
|
+
description: 'Pack a folder of this company\'s data folder into one .tar.gz backup file (its backups/ and tmp/ always left out). Returns { filePath, fileName, bytes, sha256, folder, warning }.',
|
|
79
|
+
requires: [], // tar is checked when it runs
|
|
80
|
+
|
|
81
|
+
inputSchema: [
|
|
82
|
+
{ name: 'folder', type: 'string', order: 1,
|
|
83
|
+
description: 'A folder in this company\'s data folder, e.g. uploads. Blank or "." for all of it.' },
|
|
84
|
+
{ name: 'exclude', type: 'string', order: 2,
|
|
85
|
+
description: 'Names or patterns to leave out, comma separated — e.g. node_modules, *.log.' },
|
|
86
|
+
{ name: 'fileName', type: 'string', order: 3, group: 'File',
|
|
87
|
+
description: 'The archive\'s name. Default: <folder>-<time>.tar.gz.' }
|
|
88
|
+
],
|
|
89
|
+
outputSchema: [
|
|
90
|
+
{ name: 'filePath', type: 'string' }, { name: 'fileName', type: 'string' }, { name: 'bytes', type: 'number' },
|
|
91
|
+
{ name: 'sha256', type: 'string' }, { name: 'folder', type: 'string' }, { name: 'warning', type: 'string' }
|
|
92
|
+
],
|
|
93
|
+
|
|
94
|
+
execute: async function(ctx) {
|
|
95
|
+
var input = ctx.input || {};
|
|
96
|
+
var system = ctx.system || {};
|
|
97
|
+
var target = targetOf(input);
|
|
98
|
+
var excludes = excludesOf(input.exclude);
|
|
99
|
+
var outDir = dataDir.resolve(OUT_FOLDER, 'The backup folder');
|
|
100
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
101
|
+
var name = input.fileName ? String(input.fileName) : slugOf(target.rel) + '-' + stamp(new Date()) + '.tar.gz';
|
|
102
|
+
var filePath = dataDir.resolve(path.join(outDir, name), 'The backup file');
|
|
103
|
+
if (path.dirname(filePath) !== fs.realpathSync(outDir)) throw refuse('folder-archive: fileName "' + name + '" must be a name, not a path.', 'PATH_NOT_ALLOWED');
|
|
104
|
+
var partial = filePath + '.partial';
|
|
105
|
+
|
|
106
|
+
var args = ['-czf', partial].concat(excludes.map(function(p) { return '--exclude=' + p; }), ['-C', target.cwd, '--'], target.members);
|
|
107
|
+
var stderr = '';
|
|
108
|
+
var child;
|
|
109
|
+
var code = await new Promise(function(resolve, reject) {
|
|
110
|
+
try {
|
|
111
|
+
child = spawn('tar', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
112
|
+
} catch (e) {
|
|
113
|
+
reject(e);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
child.on('error', function(e) { reject(e.code === 'ENOENT' ? refuse('tar is not installed on this server.', 'PROGRAM_NOT_FOUND') : e); });
|
|
117
|
+
child.stderr.on('data', function(d) { stderr = (stderr + d.toString()).slice(-STDERR_KEEP); });
|
|
118
|
+
var onAbort = function() { if (child.exitCode === null) child.kill('SIGTERM'); };
|
|
119
|
+
if (system.signal) {
|
|
120
|
+
if (system.signal.aborted) onAbort();
|
|
121
|
+
else system.signal.addEventListener('abort', onAbort, { once: true });
|
|
122
|
+
}
|
|
123
|
+
child.on('close', function(c, signal) {
|
|
124
|
+
if (system.signal) system.signal.removeEventListener('abort', onAbort);
|
|
125
|
+
if (signal) return reject(refuse('tar was stopped (' + signal + ').', 'ARCHIVE_FAILED'));
|
|
126
|
+
resolve(c);
|
|
127
|
+
});
|
|
128
|
+
}).catch(function(err) {
|
|
129
|
+
try { fs.unlinkSync(partial); } catch (_) {}
|
|
130
|
+
throw err;
|
|
131
|
+
});
|
|
132
|
+
var said = stderr.trim().split('\n').slice(-3).join(' ');
|
|
133
|
+
if (code !== 0 && code !== 1) {
|
|
134
|
+
try { fs.unlinkSync(partial); } catch (_) {}
|
|
135
|
+
throw refuse('tar ended with code ' + code + (said ? ': ' + said : ''), 'ARCHIVE_FAILED');
|
|
136
|
+
}
|
|
137
|
+
fs.renameSync(partial, filePath);
|
|
138
|
+
var hash = crypto.createHash('sha256');
|
|
139
|
+
await new Promise(function(resolve, reject) {
|
|
140
|
+
fs.createReadStream(filePath).on('data', function(d) { hash.update(d); }).on('end', resolve).on('error', reject);
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
filePath: filePath, fileName: path.basename(filePath), bytes: fs.statSync(filePath).size, sha256: hash.digest('hex'),
|
|
144
|
+
folder: target.rel || '.', warning: code === 1 ? (said || 'some files changed while they were read') : null
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
// For tests.
|
|
149
|
+
_targetOf: targetOf,
|
|
150
|
+
_excludesOf: excludesOf
|
|
151
|
+
};
|
package/lib/builtins/index.js
CHANGED
|
@@ -59,6 +59,9 @@ module.exports = {
|
|
|
59
59
|
// One file to an S3 bucket, in parts, write-only. Peers: @aws-sdk/client-s3
|
|
60
60
|
// and @aws-sdk/lib-storage — optional, lazy-required.
|
|
61
61
|
s3Upload: require('./file/s3-upload'),
|
|
62
|
+
// A folder of the tenant's own data folder to one .tar.gz backup file (tar,
|
|
63
|
+
// no shell; its backups/ and tmp/ always left out).
|
|
64
|
+
folderArchive: require('./file/archive'),
|
|
62
65
|
|
|
63
66
|
// Email actions. Inbound routes to getInboundProvider(input.emailType)
|
|
64
67
|
// (imap today), outbound to getOutboundProvider (smtp) — see
|
package/package.json
CHANGED