storymaps 1.2.9 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/bin/storymaps.js +26 -1
- package/lib/commands/backup.js +92 -0
- package/lib/commands/log.js +1 -10
- package/lib/commands/status.js +28 -8
- package/lib/util/api.js +25 -0
- package/lib/util/time.js +12 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -138,9 +138,10 @@ storymaps diff file1.yml file2.yml # compare two local files
|
|
|
138
138
|
```bash
|
|
139
139
|
storymaps status # progress overview of storymap.yml
|
|
140
140
|
storymaps status other.yml # progress overview of a specific file
|
|
141
|
+
storymaps status storymaps.io/abc123 # progress overview of a map on the server, no file needed
|
|
141
142
|
```
|
|
142
143
|
|
|
143
|
-
Progress overview: slices, status counts, and blocked stories.
|
|
144
|
+
Progress overview: slices, status counts, and blocked stories. A link to a map inside a project works too.
|
|
144
145
|
|
|
145
146
|
### `log`
|
|
146
147
|
|
|
@@ -159,6 +160,18 @@ storymaps unlock # session unlock
|
|
|
159
160
|
storymaps unlock --remove # permanently remove the lock
|
|
160
161
|
```
|
|
161
162
|
|
|
163
|
+
### `backup` / `backups` / `restore`
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
storymaps backup # back up the map on the server
|
|
167
|
+
storymaps backup -m "Before the workshop" # with a note
|
|
168
|
+
storymaps backups # list the backups, newest first
|
|
169
|
+
storymaps restore 7ed8450a830a # restore one, after backing up the map as it is now
|
|
170
|
+
storymaps restore 7ed8450a830a --force # restore without asking first
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
These are the same backups as **Menu → Backups** in the app, and the server keeps the newest 8 of each map. A restore changes the map on the server, not your local file, so run `storymaps pull` afterwards. Until you do, `storymaps push` stops with a conflict, so an old local file can't undo the restore. On a locked map, restore asks for the password, or uses the one saved by `storymaps unlock`. A self-hosted server needs updating before the CLI can make backups on it.
|
|
174
|
+
|
|
162
175
|
### `validate`
|
|
163
176
|
|
|
164
177
|
```bash
|
package/bin/storymaps.js
CHANGED
|
@@ -17,6 +17,7 @@ import { lockCommand } from '../lib/commands/lock.js';
|
|
|
17
17
|
import { unlockCommand } from '../lib/commands/unlock.js';
|
|
18
18
|
import { statusCommand } from '../lib/commands/status.js';
|
|
19
19
|
import { logCommand } from '../lib/commands/log.js';
|
|
20
|
+
import { backupCommand, backupsCommand, restoreCommand } from '../lib/commands/backup.js';
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
const program = new Command();
|
|
@@ -98,7 +99,7 @@ program
|
|
|
98
99
|
program
|
|
99
100
|
.command('status')
|
|
100
101
|
.description('Show a progress overview of the story map')
|
|
101
|
-
.argument('[
|
|
102
|
+
.argument('[target]', 'storymap file, or a map ID, domain/ID, or full URL')
|
|
102
103
|
.option('-f, --file <path>', 'path to storymap file', 'storymap.yml')
|
|
103
104
|
.action(statusCommand);
|
|
104
105
|
|
|
@@ -109,6 +110,30 @@ program
|
|
|
109
110
|
.option('-f, --file <path>', 'local file path', 'storymap.yml')
|
|
110
111
|
.action(logCommand);
|
|
111
112
|
|
|
113
|
+
program
|
|
114
|
+
.command('backup')
|
|
115
|
+
.description('Back up a map on the server')
|
|
116
|
+
.argument('[target]', 'map ID, domain/ID, or full URL')
|
|
117
|
+
.option('-f, --file <path>', 'local file path', 'storymap.yml')
|
|
118
|
+
.option('-m, --message <note>', 'a note to tell the backup apart')
|
|
119
|
+
.action(backupCommand);
|
|
120
|
+
|
|
121
|
+
program
|
|
122
|
+
.command('backups')
|
|
123
|
+
.description('List the backups of a map, newest first')
|
|
124
|
+
.argument('[target]', 'map ID, domain/ID, or full URL')
|
|
125
|
+
.option('-f, --file <path>', 'local file path', 'storymap.yml')
|
|
126
|
+
.action(backupsCommand);
|
|
127
|
+
|
|
128
|
+
program
|
|
129
|
+
.command('restore')
|
|
130
|
+
.description('Restore a map from one of its backups, after backing up the map as it is now')
|
|
131
|
+
.argument('<backup>', 'backup ID, from storymaps backups')
|
|
132
|
+
.argument('[target]', 'map ID, domain/ID, or full URL')
|
|
133
|
+
.option('-f, --file <path>', 'local file path', 'storymap.yml')
|
|
134
|
+
.option('--force', 'restore without asking first')
|
|
135
|
+
.action(restoreCommand);
|
|
136
|
+
|
|
112
137
|
program.parseAsync().catch(err => {
|
|
113
138
|
const message = err.name === 'ExitPromptError' ? 'Cancelled.'
|
|
114
139
|
: err.message || String(err);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Storymaps CLI — AGPL-3.0 — see LICENCE for details
|
|
2
|
+
// storymaps backup, backups and restore - make, list and restore backups of a map on the server
|
|
3
|
+
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { confirm, password } from '@inquirer/prompts';
|
|
8
|
+
import { resolveTarget } from '../util/identity.js';
|
|
9
|
+
import { createBackup, listBackups, getBackup, getLockStatus, unlockMap, putMap, ApiError } from '../util/api.js';
|
|
10
|
+
import { readCache, writeCache } from '../util/file.js';
|
|
11
|
+
import { formatTime } from '../util/time.js';
|
|
12
|
+
|
|
13
|
+
const cards = (n) => `${n} card${n === 1 ? '' : 's'}`;
|
|
14
|
+
|
|
15
|
+
export async function backupCommand(target, opts) {
|
|
16
|
+
const { id, baseUrl } = await resolveTarget(target, resolve(opts.file || 'storymap.yml'));
|
|
17
|
+
const backup = await createBackup(baseUrl, id, opts.message || '');
|
|
18
|
+
console.log(chalk.green(`Backed up map ${id} as ${backup.id} (${cards(backup.cardCount)})`));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function backupsCommand(target, opts) {
|
|
22
|
+
const { id, baseUrl } = await resolveTarget(target, resolve(opts.file || 'storymap.yml'));
|
|
23
|
+
const backups = await listBackups(baseUrl, id);
|
|
24
|
+
|
|
25
|
+
if (backups.length === 0) {
|
|
26
|
+
console.log(chalk.dim(`No backups yet. Run ${chalk.cyan('storymaps backup')} to make one.`));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Newest first, like the log
|
|
31
|
+
for (const b of [...backups].reverse()) {
|
|
32
|
+
const note = b.note || chalk.dim('(no note)');
|
|
33
|
+
const imported = b.imported ? chalk.dim(' imported') : '';
|
|
34
|
+
console.log(`${chalk.cyan(b.id)} ${chalk.dim(formatTime(b.timestamp))} ${cards(b.cardCount).padEnd(9)} ${note}${imported}`);
|
|
35
|
+
}
|
|
36
|
+
console.log(chalk.dim(`\nRestore one with ${chalk.cyan('storymaps restore <backup>')}`));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The lock password for a locked map, checked with the server before anything changes.
|
|
40
|
+
// The saved one from storymaps unlock is tried first.
|
|
41
|
+
async function lockPassword(baseUrl, id) {
|
|
42
|
+
const { isLocked } = await getLockStatus(baseUrl, id);
|
|
43
|
+
if (!isLocked) return undefined;
|
|
44
|
+
|
|
45
|
+
const saved = (await readCache(id)).hash;
|
|
46
|
+
if (saved) {
|
|
47
|
+
try {
|
|
48
|
+
await unlockMap(baseUrl, id, saved);
|
|
49
|
+
return saved;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (!(err instanceof ApiError && err.status === 403)) throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(chalk.yellow('Map is locked.'));
|
|
56
|
+
const pw = await password({ theme: { prefix: '>' }, message: 'Password:' });
|
|
57
|
+
const hash = createHash('sha256').update(pw).digest('hex');
|
|
58
|
+
await unlockMap(baseUrl, id, hash);
|
|
59
|
+
await writeCache(id, { hash });
|
|
60
|
+
return hash;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function restoreCommand(backupId, target, opts) {
|
|
64
|
+
const filePath = resolve(opts.file || 'storymap.yml');
|
|
65
|
+
const { id, baseUrl } = await resolveTarget(target, filePath);
|
|
66
|
+
const backup = await getBackup(baseUrl, id, backupId);
|
|
67
|
+
const note = backup.note ? `, "${backup.note}"` : '';
|
|
68
|
+
console.log(`Backup ${backup.id} from ${formatTime(backup.timestamp)}: ${backup.mapName || 'Untitled'}, ${cards(backup.cardCount)}${note}`);
|
|
69
|
+
|
|
70
|
+
if (!opts.force) {
|
|
71
|
+
const ok = await confirm({
|
|
72
|
+
message: 'Restore this backup? The map is backed up first.',
|
|
73
|
+
default: true,
|
|
74
|
+
theme: { prefix: '>' },
|
|
75
|
+
});
|
|
76
|
+
if (!ok) {
|
|
77
|
+
console.log('Aborted.');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const lockPasswordHash = await lockPassword(baseUrl, id);
|
|
83
|
+
|
|
84
|
+
// Like the app: a backup of the map as it is now, so the restore can be undone
|
|
85
|
+
const safety = await createBackup(baseUrl, id, 'Auto: before restore');
|
|
86
|
+
await putMap(baseUrl, id, backup.data, { contentType: 'application/json', lockPasswordHash });
|
|
87
|
+
|
|
88
|
+
console.log(chalk.green(`Restored map ${id} from backup ${backup.id}`));
|
|
89
|
+
console.log(chalk.dim(`The map before the restore is backup ${safety.id}.`));
|
|
90
|
+
// The saved ETag is left alone, so a push from an old local file stops with a conflict
|
|
91
|
+
console.log(`Run ${chalk.cyan('storymaps pull')} to bring the restored map into your local file.`);
|
|
92
|
+
}
|
package/lib/commands/log.js
CHANGED
|
@@ -5,16 +5,7 @@ import { resolve } from 'node:path';
|
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import { resolveTarget } from '../util/identity.js';
|
|
7
7
|
import { getLog } from '../util/api.js';
|
|
8
|
-
|
|
9
|
-
function formatTime(ts) {
|
|
10
|
-
const d = new Date(ts);
|
|
11
|
-
const now = new Date();
|
|
12
|
-
const pad = (n) => String(n).padStart(2, '0');
|
|
13
|
-
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
14
|
-
|
|
15
|
-
if (d.toDateString() === now.toDateString()) return time;
|
|
16
|
-
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`;
|
|
17
|
-
}
|
|
8
|
+
import { formatTime } from '../util/time.js';
|
|
18
9
|
|
|
19
10
|
function sourceLabel(entry) {
|
|
20
11
|
if (entry.src === 'cli') return '>_';
|
package/lib/commands/status.js
CHANGED
|
@@ -5,6 +5,8 @@ import { readFile } from 'node:fs/promises';
|
|
|
5
5
|
import { resolve } from 'node:path';
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import jsyaml from 'js-yaml';
|
|
8
|
+
import { resolveTarget } from '../util/identity.js';
|
|
9
|
+
import { getMap, ApiError } from '../util/api.js';
|
|
8
10
|
|
|
9
11
|
const STATUS_ICONS = {
|
|
10
12
|
'done': chalk.green('●'),
|
|
@@ -24,16 +26,34 @@ function pct(n, total) {
|
|
|
24
26
|
return `${Math.round((n / total) * 100)}%`;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// A local file, or else the map on the server when the target is a link or map ID
|
|
30
|
+
async function loadContent(target, opts) {
|
|
31
|
+
const isUrl = target && /^https?:\/\//i.test(target);
|
|
32
|
+
const filePath = resolve((!isUrl && target) || opts.file || 'storymap.yml');
|
|
33
|
+
if (!isUrl) {
|
|
34
|
+
try {
|
|
35
|
+
return await readFile(filePath, 'utf-8');
|
|
36
|
+
} catch {
|
|
37
|
+
if (!target) {
|
|
38
|
+
console.error(chalk.red(`File not found: ${filePath}`));
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const { id, baseUrl } = await resolveTarget(target, resolve(opts.file || 'storymap.yml'));
|
|
31
44
|
try {
|
|
32
|
-
|
|
33
|
-
} catch {
|
|
34
|
-
|
|
35
|
-
|
|
45
|
+
return (await getMap(baseUrl, id, 'yaml')).data;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err instanceof ApiError && err.status === 404 && !isUrl && !target.includes('/')) {
|
|
48
|
+
console.error(chalk.red(`No file or map found: ${target}`));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
36
52
|
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function statusCommand(target, opts) {
|
|
56
|
+
const content = await loadContent(target, opts);
|
|
37
57
|
|
|
38
58
|
const map = jsyaml.load(content);
|
|
39
59
|
if (!map || typeof map !== 'object') {
|
package/lib/util/api.js
CHANGED
|
@@ -120,4 +120,29 @@ export async function getLog(baseUrl, id) {
|
|
|
120
120
|
return res.json();
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
export async function listBackups(baseUrl, id) {
|
|
124
|
+
const res = await request(`${baseUrl}/api/backups/${id}`);
|
|
125
|
+
await handleResponse(res);
|
|
126
|
+
return res.json();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function getBackup(baseUrl, id, backupId) {
|
|
130
|
+
const res = await request(`${baseUrl}/api/backups/${id}/${encodeURIComponent(backupId)}`);
|
|
131
|
+
if (res.status === 404) throw new ApiError(`Backup not found: ${backupId}`, 404);
|
|
132
|
+
await handleResponse(res);
|
|
133
|
+
return res.json();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function createBackup(baseUrl, id, note = '') {
|
|
137
|
+
const res = await request(`${baseUrl}/api/backups/${id}`, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'Content-Type': 'application/json' },
|
|
140
|
+
body: JSON.stringify({ note }),
|
|
141
|
+
});
|
|
142
|
+
// Servers from before the CLI could make backups turn the request away
|
|
143
|
+
if (res.status === 403) throw new ApiError('This server does not take backups from the CLI yet. Make the backup in the app, or update the server', 403);
|
|
144
|
+
await handleResponse(res);
|
|
145
|
+
return res.json();
|
|
146
|
+
}
|
|
147
|
+
|
|
123
148
|
export { ApiError };
|
package/lib/util/time.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Storymaps CLI — AGPL-3.0 — see LICENCE for details
|
|
2
|
+
// Short local times: HH:MM for today, YYYY-MM-DD HH:MM for other days
|
|
3
|
+
|
|
4
|
+
export function formatTime(ts) {
|
|
5
|
+
const d = new Date(ts);
|
|
6
|
+
const now = new Date();
|
|
7
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
8
|
+
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
9
|
+
|
|
10
|
+
if (d.toDateString() === now.toDateString()) return time;
|
|
11
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`;
|
|
12
|
+
}
|