storymaps 1.2.8 → 1.3.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 +42 -3
- package/bin/storymaps.js +25 -0
- package/lib/commands/backup.js +92 -0
- package/lib/commands/log.js +1 -10
- package/lib/util/api.js +25 -0
- package/lib/util/time.js +12 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,6 +23,12 @@ Story maps are structured data. The CLI lets you export the structure behind the
|
|
|
23
23
|
npm install -g storymaps
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
Or run it without installing, the way the Build with AI command on storymaps.io does:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx storymaps pull storymaps.io/abc123
|
|
30
|
+
```
|
|
31
|
+
|
|
26
32
|
Or clone and link locally:
|
|
27
33
|
|
|
28
34
|
```bash
|
|
@@ -43,6 +49,7 @@ Or pull an existing map:
|
|
|
43
49
|
|
|
44
50
|
```bash
|
|
45
51
|
storymaps pull storymaps.io/abc123 # download as YAML
|
|
52
|
+
storymaps pull storymaps.io/p/k8f2m9xq/abc123 # a map inside a project link works too
|
|
46
53
|
```
|
|
47
54
|
|
|
48
55
|
Edit the YAML, then push it back:
|
|
@@ -104,17 +111,19 @@ storymaps open storymaps.io/abc123 # open a specific remote map
|
|
|
104
111
|
```bash
|
|
105
112
|
storymaps pull # uses id/site from storymap.yml
|
|
106
113
|
storymaps pull storymaps.io/abc123 # pull a specific map
|
|
114
|
+
storymaps pull --force # overwrite the local file without asking
|
|
107
115
|
```
|
|
108
116
|
|
|
109
|
-
Shows a summary of changes before overwriting
|
|
117
|
+
Shows a summary of changes and asks before overwriting the local file, unless you pass `--force`.
|
|
110
118
|
|
|
111
119
|
### `push`
|
|
112
120
|
|
|
113
121
|
```bash
|
|
114
122
|
storymaps push # push local changes to the server
|
|
123
|
+
storymaps push --force # skip the conflict check and overwrite the server copy
|
|
115
124
|
```
|
|
116
125
|
|
|
117
|
-
If the file has no `id`, a new map is created on the server. Conflicts are detected using ETags. If the remote has changed since your last pull, you'll be prompted to pull first. Locked maps prompt for the password.
|
|
126
|
+
If the file has no `id`, a new map is created on the server. Conflicts are detected using ETags. If the remote has changed since your last pull, you'll be prompted to pull first; `--force` pushes anyway and replaces whatever changed on the server. Locked maps prompt for the password.
|
|
118
127
|
|
|
119
128
|
### `diff`
|
|
120
129
|
|
|
@@ -150,12 +159,30 @@ storymaps unlock # session unlock
|
|
|
150
159
|
storymaps unlock --remove # permanently remove the lock
|
|
151
160
|
```
|
|
152
161
|
|
|
162
|
+
### `backup` / `backups` / `restore`
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
storymaps backup # back up the map on the server
|
|
166
|
+
storymaps backup -m "Before the workshop" # with a note
|
|
167
|
+
storymaps backups # list the backups, newest first
|
|
168
|
+
storymaps restore 7ed8450a830a # restore one, after backing up the map as it is now
|
|
169
|
+
storymaps restore 7ed8450a830a --force # restore without asking first
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
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.
|
|
173
|
+
|
|
153
174
|
### `validate`
|
|
154
175
|
|
|
155
176
|
```bash
|
|
156
177
|
storymaps validate storymap.yml # check YAML structure
|
|
157
178
|
```
|
|
158
179
|
|
|
180
|
+
### Options
|
|
181
|
+
|
|
182
|
+
A target can be a map ID, `domain/id`, or a full URL, including a map inside a project link (`storymaps.io/p/<project>/<map>`).
|
|
183
|
+
|
|
184
|
+
`pull`, `push`, `open`, `lock`, `unlock`, `status` and `log` take `-f, --file <path>` to use a file other than `storymap.yml`.
|
|
185
|
+
|
|
159
186
|
### `convert`
|
|
160
187
|
|
|
161
188
|
```bash
|
|
@@ -259,6 +286,16 @@ Cards support: `name`, `body`, `color`, `status` (done / in-progress / planned /
|
|
|
259
286
|
|
|
260
287
|
Slices support: `name`, `stories`, `goal` (up to 200 characters), `targetDate` (`YYYY-MM-DD`), and `collapsed` with `closedReason` (complete / closed).
|
|
261
288
|
|
|
289
|
+
## Use with AI Coding Agents
|
|
290
|
+
|
|
291
|
+
A story map makes a good specification for an AI coding agent: the YAML lists every step of the journey and the cards under each one, slice by slice. **Build with AI** on storymaps.io writes the command for you:
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
npx storymaps pull --force storymaps.io/abc123 && claude "The storymap file @storymap.yml describes a product plan in user story map format. Use it as a structured specification. Your task: build a functional prototype of the features listed in the 'MVP' slice."
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
From there you steer the agent a slice or a feature at a time ("now build search from the V1 slice"). When a feature has been through QA, set its cards to `status: done` in `storymap.yml` and `storymaps push`, so the map shows the progress to everyone else. Pull again before the next round to pick up changes made in the browser.
|
|
298
|
+
|
|
262
299
|
## Self-Hosting
|
|
263
300
|
|
|
264
301
|
The CLI works with any storymaps.io instance:
|
|
@@ -269,6 +306,8 @@ storymaps init --site storymaps.yourcompany.com
|
|
|
269
306
|
|
|
270
307
|
See the [main project README](https://github.com/jackgleeson/storymaps.io#self-hosting) for setup instructions.
|
|
271
308
|
|
|
309
|
+
Every request names the CLI and its version in the User-Agent header (`storymaps-cli/<version>`), so CLI use shows up as such in your server logs.
|
|
310
|
+
|
|
272
311
|
## Development
|
|
273
312
|
|
|
274
313
|
```bash
|
|
@@ -283,7 +322,7 @@ To try a change against a local storymaps.io server, run the CLI from the repo,
|
|
|
283
322
|
### Releasing
|
|
284
323
|
|
|
285
324
|
1. Commit your changes on `main`.
|
|
286
|
-
2. Bump the version in `package.json` and commit it as "Bump version to x.y.z". Use a patch for fixes and a minor version for new features.
|
|
325
|
+
2. Bump the version in `package.json` and in both places near the top of `package-lock.json`, and commit it as "Bump version to x.y.z". Use a patch for fixes and a minor version for new features.
|
|
287
326
|
3. Tag the commit and push both:
|
|
288
327
|
```bash
|
|
289
328
|
git tag vx.y.z
|
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();
|
|
@@ -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/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
|
+
}
|