storymaps 1.3.4 → 1.4.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 +105 -1
- package/bin/storymaps.js +89 -26
- package/lib/commands/backup.js +37 -35
- package/lib/commands/diff.js +58 -38
- package/lib/commands/init.js +12 -3
- package/lib/commands/lock.js +42 -20
- package/lib/commands/open.js +21 -3
- package/lib/commands/project-sync.js +509 -0
- package/lib/commands/project.js +291 -0
- package/lib/commands/pull.js +59 -19
- package/lib/commands/push.js +34 -24
- package/lib/commands/status.js +40 -3
- package/lib/commands/unlock.js +34 -19
- package/lib/commands/validate.js +29 -0
- package/lib/core/yaml.js +23 -0
- package/lib/util/api.js +78 -6
- package/lib/util/file.js +15 -0
- package/lib/util/full-diff.js +42 -0
- package/lib/util/identity.js +34 -13
- package/lib/util/passwords.js +101 -0
- package/lib/util/project.js +215 -0
- package/lib/util/prompt.js +73 -0
- package/lib/util/summary.js +4 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,6 +48,15 @@ npx storymaps pull --force storymaps.io/abc123 && claude "The storymap file @sto
|
|
|
48
48
|
|
|
49
49
|
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.
|
|
50
50
|
|
|
51
|
+
For a [project](#projects) of several maps, pull the whole project, so the agent gets every map as one specification in `storymaps/`:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
storymaps pull storymaps.io/p/k8f2m9xq # every map, into storymaps/
|
|
55
|
+
storymaps project unlock --maps # if it's locked: once, in your terminal
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Unlocking once remembers the password on this computer, so the agent can push without ever seeing it. Agents should pass `--yes` to take default answers, as some agent tools run commands in a terminal where a question would wait. In CI, give the password with `STORYMAPS_PASSWORD` instead.
|
|
59
|
+
|
|
51
60
|
## Quick Start
|
|
52
61
|
|
|
53
62
|
```bash
|
|
@@ -69,6 +78,82 @@ storymaps diff # see what changed
|
|
|
69
78
|
storymaps push # sync to the server
|
|
70
79
|
```
|
|
71
80
|
|
|
81
|
+
## Projects
|
|
82
|
+
|
|
83
|
+
A project groups several story maps, such as one per team or per part of a product. The CLI keeps a whole project in one `storymaps/` folder, one file per map, named by map ID so renaming a map in the app never puts a file out of step:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
storymaps pull storymaps.io/p/k8f2m9xq # every map in the project, into storymaps/
|
|
87
|
+
storymaps status # one line per map, and a total
|
|
88
|
+
storymaps push # checks every map first, then pushes the ones that changed
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
my-repo/
|
|
93
|
+
└── storymaps/
|
|
94
|
+
├── 1g2pt83x.yml CLI
|
|
95
|
+
├── wtuzo7nw.yml Web App
|
|
96
|
+
└── 8166fkm0.yml MCP Server
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Each file says which project it's in, straight after its `id`:
|
|
100
|
+
|
|
101
|
+
```yaml
|
|
102
|
+
name: CLI
|
|
103
|
+
id: 1g2pt83x
|
|
104
|
+
project: k8f2m9xq
|
|
105
|
+
site: storymaps.io
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- **One rule.** `storymaps <command>` works on maps: one map with `-f` or a link, or every map in the project folder without one. `storymaps project <command>` changes the project itself.
|
|
109
|
+
- **A link to one map in a project** asks the first time whether you want just that map or the whole project, since the app's address bar always shows a map inside its project.
|
|
110
|
+
- **Push checks everything before it sends anything**: every file, every conflict and every password. A project never ends up half pushed. A file this computer never pulled, such as one from a fresh clone, stops push until you pull.
|
|
111
|
+
- **The CLI never moves, renames or deletes your files**, so a map open in an editor never looks deleted. The one exception is a map started offline whose ID turns out to be taken by another map: it gets a fresh ID, and its file is renamed to match.
|
|
112
|
+
- **A map removed from the project in the app** keeps its file, and pull takes its `project` key out. Push never adds a map back by accident.
|
|
113
|
+
- **A project in reach wins**: `storymaps/` below you, or project files in the current folder, so commands work from the repo root. A `storymap.yml` beside a project is one `-f storymap.yml` away, and the command says so. A folder with only `storymap.yml` works exactly as before.
|
|
114
|
+
|
|
115
|
+
### `project init`
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
storymaps project init --name "Coffee shop" # a new project with a first map
|
|
119
|
+
storymaps project init --name "Coffee shop" ordering.yml loyalty.yml # a map from each file
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The files you start from are left as they are, for you to delete.
|
|
123
|
+
|
|
124
|
+
### `project add`
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
storymaps project add --name "Roadmap" # a new map, even offline
|
|
128
|
+
storymaps project add storymap.yml # a map from outside the project
|
|
129
|
+
storymaps project add storymaps.io/abc123 # an existing map, by link or ID
|
|
130
|
+
storymaps project add --project storymaps.io/p/k8f2m9xq # with no project folder yet
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Online, the map is created and added straight away. Offline, the CLI makes the map ID and the next `push` creates the map and adds it.
|
|
134
|
+
|
|
135
|
+
### `project lock` / `project unlock`
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
storymaps project lock # the project, or the project and its maps: the same two choices as the app
|
|
139
|
+
storymaps project lock --maps # include the maps that aren't locked yet, without asking
|
|
140
|
+
storymaps project unlock # on this computer
|
|
141
|
+
storymaps project unlock --maps # and the maps that use the project password
|
|
142
|
+
storymaps project unlock --remove # for everyone
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
A map with its own password keeps it. Renaming a project, and reordering, removing and deleting its maps, stay in the app.
|
|
146
|
+
|
|
147
|
+
### Questions, scripts and agents
|
|
148
|
+
|
|
149
|
+
The CLI only asks a question in a terminal. `-y, --yes` takes the default answers, and `--force` answers the questions that guard against overwriting or removing something. With no terminal and no flag, a question about how much to do takes its default and says so, and a guard stops and names the flag.
|
|
150
|
+
|
|
151
|
+
Passwords are never flags. They're typed, remembered on this computer after `storymaps unlock` or `storymaps project unlock --maps`, or taken from the `STORYMAPS_PASSWORD` environment variable, for scripts and CI:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
STORYMAPS_PASSWORD="$RELEASE_PASSWORD" storymaps project lock --maps
|
|
155
|
+
```
|
|
156
|
+
|
|
72
157
|
## Status
|
|
73
158
|
|
|
74
159
|
See the project status in a snapshot:
|
|
@@ -98,6 +183,24 @@ Blocked
|
|
|
98
183
|
|
|
99
184
|
Non-task cards (notes, questions) are excluded from counts based on the legend.
|
|
100
185
|
|
|
186
|
+
In a project folder, `status` shows one line per map, in tab order, with its file and its lock, and a total:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
> storymaps status
|
|
190
|
+
|
|
191
|
+
Coffee shop
|
|
192
|
+
https://storymaps.io/p/k8f2m9xq
|
|
193
|
+
project locked
|
|
194
|
+
|
|
195
|
+
Ordering rq4m8z2c.yml ████████████████░░░░ 12/15 (80%) locked
|
|
196
|
+
Loyalty bt7k1w9e.yml ██████░░░░░░░░░░░░░░ 4/13 (31%) locked
|
|
197
|
+
Barista hx2p6n0d.yml ░░░░░░░░░░░░░░░░░░░░ 0/9 (0%) own lock
|
|
198
|
+
|
|
199
|
+
Total ███████░░░░░░░░░░░░░ 16/37 stories (43%)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`locked` is a map locked with the project password, and `own lock` a map with its own password.
|
|
203
|
+
|
|
101
204
|
## Commands
|
|
102
205
|
|
|
103
206
|
### `init`
|
|
@@ -141,6 +244,7 @@ If the file has no `id`, a new map is created on the server. Conflicts are detec
|
|
|
141
244
|
storymaps diff # summary of changes vs remote
|
|
142
245
|
storymaps diff --full # full line-by-line diff
|
|
143
246
|
storymaps diff file1.yml file2.yml # compare two local files
|
|
247
|
+
storymaps diff storymaps.io/abc123 # the local file of that map, compared with the server
|
|
144
248
|
```
|
|
145
249
|
|
|
146
250
|
### `status`
|
|
@@ -225,7 +329,7 @@ storymaps validate storymap.yml # check YAML structure
|
|
|
225
329
|
|
|
226
330
|
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>`).
|
|
227
331
|
|
|
228
|
-
`pull`, `push`, `open`, `lock`, `unlock`, `status`, `log`, `backup`, `backups` and `restore` take `-f, --file <path>` to use a file other than `storymap.yml`.
|
|
332
|
+
`pull`, `push`, `open`, `lock`, `unlock`, `status`, `log`, `backup`, `backups` and `restore` take `-f, --file <path>` to use a file other than `storymap.yml`. In a project folder, `pull`, `push`, `status`, `diff`, `open`, `backup` and `backups` work on every map without `-f`, and take `--dir <folder>` for a project folder other than `storymaps`. `validate storymaps` checks every map file in the folder, and `restore` finds which map a backup ID belongs to.
|
|
229
333
|
|
|
230
334
|
### `convert`
|
|
231
335
|
|
package/bin/storymaps.js
CHANGED
|
@@ -18,17 +18,21 @@ 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
20
|
import { backupCommand, backupsCommand, restoreCommand } from '../lib/commands/backup.js';
|
|
21
|
+
import { projectInitCommand, projectAddCommand, projectLockCommand, projectUnlockCommand } from '../lib/commands/project.js';
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
const program = new Command();
|
|
24
25
|
|
|
25
26
|
program
|
|
26
27
|
.name('storymaps')
|
|
27
|
-
.description('CLI companion for storymaps.io
|
|
28
|
+
.description('CLI companion for storymaps.io: manage user story maps from the terminal')
|
|
28
29
|
.version(pkg.version)
|
|
29
30
|
.showHelpAfterError()
|
|
30
31
|
.addHelpText('after', '\nRun storymaps <command> --help for details on a specific command.');
|
|
31
32
|
|
|
33
|
+
const FILE_HELP = 'one map\'s file (default: storymap.yml, or every map in a project folder)';
|
|
34
|
+
const DIR_HELP = 'the project folder (default: storymaps)';
|
|
35
|
+
|
|
32
36
|
program
|
|
33
37
|
.command('init')
|
|
34
38
|
.description('Create a new storymap.yml in the current directory')
|
|
@@ -37,8 +41,8 @@ program
|
|
|
37
41
|
|
|
38
42
|
program
|
|
39
43
|
.command('validate')
|
|
40
|
-
.description('Validate a storymap YAML or JSON file')
|
|
41
|
-
.argument('<file>', 'path to storymap file')
|
|
44
|
+
.description('Validate a storymap YAML or JSON file, or every map file in a folder')
|
|
45
|
+
.argument('<file>', 'path to a storymap file, or a folder such as storymaps')
|
|
42
46
|
.action(validateCommand);
|
|
43
47
|
|
|
44
48
|
program
|
|
@@ -52,77 +56,85 @@ program
|
|
|
52
56
|
|
|
53
57
|
program
|
|
54
58
|
.command('pull')
|
|
55
|
-
.description('Download a map from the remote server')
|
|
56
|
-
.argument('[target]', 'map ID, domain/ID, or
|
|
57
|
-
.option('-f, --file <path>',
|
|
58
|
-
.option('--
|
|
59
|
+
.description('Download a map, or a whole project, from the remote server')
|
|
60
|
+
.argument('[target]', 'map ID, domain/ID, full URL, or a project link such as storymaps.io/p/<project>')
|
|
61
|
+
.option('-f, --file <path>', FILE_HELP)
|
|
62
|
+
.option('--dir <folder>', DIR_HELP)
|
|
63
|
+
.option('--force', 'overwrite local files without asking')
|
|
64
|
+
.option('-y, --yes', 'take the default answers, such as just the map for a link to a map in a project')
|
|
59
65
|
.action(pullCommand);
|
|
60
66
|
|
|
61
67
|
program
|
|
62
68
|
.command('push')
|
|
63
|
-
.description('Upload a local map to the remote server')
|
|
69
|
+
.description('Upload a local map, or every map in a project folder, to the remote server')
|
|
64
70
|
.argument('[target]', 'map ID, domain/ID, or full URL')
|
|
65
|
-
.option('-f, --file <path>',
|
|
71
|
+
.option('-f, --file <path>', FILE_HELP)
|
|
72
|
+
.option('--dir <folder>', DIR_HELP)
|
|
66
73
|
.option('--force', 'skip conflict check and overwrite remote')
|
|
67
74
|
.action(pushCommand);
|
|
68
75
|
|
|
69
76
|
program
|
|
70
77
|
.command('diff')
|
|
71
|
-
.description('Compare a local file against the remote or another local file')
|
|
72
|
-
.argument('[source]', 'local file to compare (default: storymap.yml)')
|
|
78
|
+
.description('Compare a local file against the remote or another local file, or every map in a project folder')
|
|
79
|
+
.argument('[source]', 'local file to compare, or a map link or ID to compare its local file (default: storymap.yml)')
|
|
73
80
|
.argument('[target]', 'second local file (omit to compare against remote)')
|
|
74
81
|
.option('--full', 'show the full file instead of just changed regions')
|
|
82
|
+
.option('--dir <folder>', DIR_HELP)
|
|
75
83
|
.action(diffCommand);
|
|
76
84
|
|
|
77
85
|
program
|
|
78
86
|
.command('open')
|
|
79
|
-
.description('Open a map in the default browser')
|
|
87
|
+
.description('Open a map, or a project, in the default browser')
|
|
80
88
|
.argument('[target]', 'map ID, domain/ID, or full URL')
|
|
81
|
-
.option('-f, --file <path>',
|
|
89
|
+
.option('-f, --file <path>', FILE_HELP)
|
|
90
|
+
.option('--dir <folder>', DIR_HELP)
|
|
82
91
|
.action(openCommand);
|
|
83
92
|
|
|
84
93
|
program
|
|
85
94
|
.command('lock')
|
|
86
|
-
.description('Password-protect a map')
|
|
95
|
+
.description('Password-protect a map. For a whole project, use storymaps project lock')
|
|
87
96
|
.argument('[target]', 'map ID, domain/ID, or full URL')
|
|
88
|
-
.option('-f, --file <path>', 'local file path
|
|
97
|
+
.option('-f, --file <path>', 'local file path (default: storymap.yml)')
|
|
89
98
|
.action(lockCommand);
|
|
90
99
|
|
|
91
100
|
program
|
|
92
101
|
.command('unlock')
|
|
93
|
-
.description('Unlock a password-protected map')
|
|
102
|
+
.description('Unlock a password-protected map. For a whole project, use storymaps project unlock')
|
|
94
103
|
.argument('[target]', 'map ID, domain/ID, or full URL')
|
|
95
|
-
.option('-f, --file <path>', 'local file path
|
|
104
|
+
.option('-f, --file <path>', 'local file path (default: storymap.yml)')
|
|
96
105
|
.option('--remove', 'permanently remove the lock instead of session unlock')
|
|
97
106
|
.action(unlockCommand);
|
|
98
107
|
|
|
99
108
|
program
|
|
100
109
|
.command('status')
|
|
101
|
-
.description('Show a progress overview of the story map')
|
|
102
|
-
.argument('[target]', 'storymap file,
|
|
103
|
-
.option('-f, --file <path>',
|
|
110
|
+
.description('Show a progress overview of the story map, or of every map in a project')
|
|
111
|
+
.argument('[target]', 'storymap file, a map ID, domain/ID, full URL, or a project link')
|
|
112
|
+
.option('-f, --file <path>', FILE_HELP)
|
|
113
|
+
.option('--dir <folder>', DIR_HELP)
|
|
104
114
|
.action(statusCommand);
|
|
105
115
|
|
|
106
116
|
program
|
|
107
117
|
.command('log')
|
|
108
118
|
.description('Show activity log for a map')
|
|
109
119
|
.argument('[target]', 'map ID, domain/ID, or full URL')
|
|
110
|
-
.option('-f, --file <path>', 'local file path
|
|
120
|
+
.option('-f, --file <path>', 'local file path (default: storymap.yml)')
|
|
111
121
|
.action(logCommand);
|
|
112
122
|
|
|
113
123
|
program
|
|
114
124
|
.command('backup')
|
|
115
|
-
.description('Back up a map on the server')
|
|
125
|
+
.description('Back up a map, or every map in a project folder, on the server')
|
|
116
126
|
.argument('[target]', 'map ID, domain/ID, or full URL')
|
|
117
|
-
.option('-f, --file <path>',
|
|
127
|
+
.option('-f, --file <path>', FILE_HELP)
|
|
128
|
+
.option('--dir <folder>', DIR_HELP)
|
|
118
129
|
.option('-m, --message <note>', 'a note to tell the backup apart')
|
|
119
130
|
.action(backupCommand);
|
|
120
131
|
|
|
121
132
|
program
|
|
122
133
|
.command('backups')
|
|
123
|
-
.description('List the backups of a map, newest first')
|
|
134
|
+
.description('List the backups of a map, newest first, or the newest backup of each map in a project')
|
|
124
135
|
.argument('[target]', 'map ID, domain/ID, or full URL')
|
|
125
|
-
.option('-f, --file <path>',
|
|
136
|
+
.option('-f, --file <path>', FILE_HELP)
|
|
137
|
+
.option('--dir <folder>', DIR_HELP)
|
|
126
138
|
.action(backupsCommand);
|
|
127
139
|
|
|
128
140
|
program
|
|
@@ -130,13 +142,64 @@ program
|
|
|
130
142
|
.description('Restore a map from one of its backups, after backing up the map as it is now')
|
|
131
143
|
.argument('<backup-id>', 'the ID of a backup, from storymaps backups')
|
|
132
144
|
.argument('[target]', 'map ID, domain/ID, or full URL')
|
|
133
|
-
.option('-f, --file <path>', 'local file path
|
|
145
|
+
.option('-f, --file <path>', 'local file path (default: storymap.yml, or the map the backup belongs to in a project folder)')
|
|
146
|
+
.option('--dir <folder>', DIR_HELP)
|
|
134
147
|
.option('--force', 'restore without asking first')
|
|
135
148
|
.action(restoreCommand);
|
|
136
149
|
|
|
150
|
+
// Commands that change a project itself. The other commands work on its maps.
|
|
151
|
+
const project = program
|
|
152
|
+
.command('project')
|
|
153
|
+
.description('Start a project, add maps to it, or lock and unlock it');
|
|
154
|
+
|
|
155
|
+
project
|
|
156
|
+
.command('init')
|
|
157
|
+
.description('Start a project with a first map, or with a map from each file, in storymaps/')
|
|
158
|
+
.argument('[files...]', 'map files to start the project from; they are left as they are')
|
|
159
|
+
.option('--name <name>', 'the project name (default: Untitled project)')
|
|
160
|
+
.option('--dir <folder>', DIR_HELP)
|
|
161
|
+
.option('-s, --site <site>', 'target site (e.g. localhost, storymaps.io)')
|
|
162
|
+
.action(projectInitCommand);
|
|
163
|
+
|
|
164
|
+
project
|
|
165
|
+
.command('add')
|
|
166
|
+
.description('Add a map to the project: a new map, a map from a file, or an existing map')
|
|
167
|
+
.argument('[map]', 'a map file, or an existing map by ID or link (default: a new blank map)')
|
|
168
|
+
.option('--name <name>', 'the name of a new map (default: My Story Map)')
|
|
169
|
+
.option('--dir <folder>', DIR_HELP)
|
|
170
|
+
.option('--project <link>', 'the project, by link or ID, when there is no project folder yet')
|
|
171
|
+
.action(projectAddCommand);
|
|
172
|
+
|
|
173
|
+
project
|
|
174
|
+
.command('lock')
|
|
175
|
+
.description('Lock the project, or the project and its maps')
|
|
176
|
+
.option('--maps', 'lock the maps that are not locked too, without asking')
|
|
177
|
+
.option('-y, --yes', 'take the default answer: the project alone')
|
|
178
|
+
.option('--dir <folder>', DIR_HELP)
|
|
179
|
+
.option('--project <link>', 'the project, by link or ID, when there is no project folder')
|
|
180
|
+
.action(projectLockCommand);
|
|
181
|
+
|
|
182
|
+
project
|
|
183
|
+
.command('unlock')
|
|
184
|
+
.description('Unlock the project on this computer, or remove its lock for everyone')
|
|
185
|
+
.option('--remove', 'remove the lock for everyone')
|
|
186
|
+
.option('--maps', 'also unlock, or with --remove remove the lock from, the maps that use the project password')
|
|
187
|
+
.option('-y, --yes', 'take the default answers: the project only, and the map locks kept')
|
|
188
|
+
.option('--dir <folder>', DIR_HELP)
|
|
189
|
+
.option('--project <link>', 'the project, by link or ID, when there is no project folder')
|
|
190
|
+
.action(projectUnlockCommand);
|
|
191
|
+
|
|
137
192
|
program.parseAsync().catch(err => {
|
|
138
193
|
const message = err.name === 'ExitPromptError' ? 'Cancelled.'
|
|
194
|
+
// fetch says only "fetch failed" when the server can't be reached
|
|
195
|
+
: err.message === 'fetch failed' ? `Couldn't reach the server${err.cause?.code ? ` (${err.cause.code})` : ''}. Check your connection, or the site in the map file, and try again.`
|
|
139
196
|
: err.message || String(err);
|
|
197
|
+
// A stop the CLI chose says what to do in its own words
|
|
198
|
+
if (err.name === 'StopError') {
|
|
199
|
+
console.error(chalk.red(message.split('\n')[0]) + (message.includes('\n') ? `\n${message.split('\n').slice(1).join('\n')}` : ''));
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
140
203
|
console.error(chalk.red(message));
|
|
141
204
|
process.exitCode = 1;
|
|
142
205
|
});
|
package/lib/commands/backup.js
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
// Storymaps CLI — AGPL-3.0 — see LICENCE for details
|
|
2
2
|
// storymaps backup, backups and restore - make, list and restore backups of a map on the server
|
|
3
3
|
|
|
4
|
-
import { createHash } from 'node:crypto';
|
|
5
4
|
import { resolve } from 'node:path';
|
|
6
5
|
import chalk from 'chalk';
|
|
7
|
-
import { confirm, password } from '@inquirer/prompts';
|
|
8
6
|
import { resolveTarget } from '../util/identity.js';
|
|
9
|
-
import { createBackup, listBackups, getBackup, getLockStatus,
|
|
10
|
-
import { readCache, writeCache } from '../util/file.js';
|
|
7
|
+
import { createBackup, listBackups, getBackup, getLockStatus, putMap } from '../util/api.js';
|
|
11
8
|
import { formatTime } from '../util/time.js';
|
|
9
|
+
import { findProjectFolder, shortPath } from '../util/project.js';
|
|
10
|
+
import { singleMapPassword } from '../util/passwords.js';
|
|
11
|
+
import { guard } from '../util/prompt.js';
|
|
12
|
+
import { backupProject, backupsProject, findBackupMap } from './project-sync.js';
|
|
12
13
|
|
|
13
14
|
const cards = (n) => `${n} card${n === 1 ? '' : 's'}`;
|
|
14
15
|
|
|
15
16
|
export async function backupCommand(target, opts) {
|
|
17
|
+
// No -f in a project folder: every map, with the same note
|
|
18
|
+
if (!target) {
|
|
19
|
+
const ctx = await findProjectFolder(opts);
|
|
20
|
+
if (ctx?.projectId) {
|
|
21
|
+
await backupProject(ctx, opts);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
16
25
|
const { id, baseUrl } = await resolveTarget(target, resolve(opts.file || 'storymap.yml'));
|
|
17
26
|
const lockPasswordHash = await lockPassword(baseUrl, id);
|
|
18
27
|
const backup = await createBackup(baseUrl, id, opts.message || '', { lockPasswordHash });
|
|
@@ -24,6 +33,14 @@ export async function backupCommand(target, opts) {
|
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
export async function backupsCommand(target, opts) {
|
|
36
|
+
// No -f in a project folder: the newest backup of each map
|
|
37
|
+
if (!target) {
|
|
38
|
+
const ctx = await findProjectFolder(opts);
|
|
39
|
+
if (ctx?.projectId) {
|
|
40
|
+
await backupsProject(ctx);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
27
44
|
const { id, baseUrl } = await resolveTarget(target, resolve(opts.file || 'storymap.yml'));
|
|
28
45
|
const backups = await listBackups(baseUrl, id);
|
|
29
46
|
|
|
@@ -41,47 +58,30 @@ export async function backupsCommand(target, opts) {
|
|
|
41
58
|
console.log(chalk.dim(`\nRestore one with ${chalk.cyan('storymaps restore <backup ID>')}`));
|
|
42
59
|
}
|
|
43
60
|
|
|
44
|
-
// The lock password for a locked map, checked with the server before anything changes
|
|
45
|
-
//
|
|
61
|
+
// The lock password for a locked map, checked with the server before anything changes:
|
|
62
|
+
// remembered after storymaps unlock, STORYMAPS_PASSWORD, or typed
|
|
46
63
|
async function lockPassword(baseUrl, id) {
|
|
47
64
|
const { isLocked } = await getLockStatus(baseUrl, id);
|
|
48
65
|
if (!isLocked) return undefined;
|
|
49
|
-
|
|
50
|
-
const saved = (await readCache(id)).hash;
|
|
51
|
-
if (saved) {
|
|
52
|
-
try {
|
|
53
|
-
await unlockMap(baseUrl, id, saved);
|
|
54
|
-
return saved;
|
|
55
|
-
} catch (err) {
|
|
56
|
-
if (!(err instanceof ApiError && err.status === 403)) throw err;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
console.log(chalk.yellow('Map is locked.'));
|
|
61
|
-
const pw = await password({ theme: { prefix: '>' }, message: 'Password:' });
|
|
62
|
-
const hash = createHash('sha256').update(pw).digest('hex');
|
|
63
|
-
await unlockMap(baseUrl, id, hash);
|
|
64
|
-
await writeCache(id, { hash });
|
|
65
|
-
return hash;
|
|
66
|
+
return singleMapPassword(baseUrl, id);
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
export async function restoreCommand(backupId, target, opts) {
|
|
69
|
-
|
|
70
|
+
// In a project folder, a backup ID belongs to one map, so the CLI finds which
|
|
71
|
+
let filePath = resolve(opts.file || 'storymap.yml');
|
|
72
|
+
if (!target && !opts.file) {
|
|
73
|
+
const ctx = await findProjectFolder(opts);
|
|
74
|
+
if (ctx?.projectId) filePath = (await findBackupMap(ctx, backupId)).path;
|
|
75
|
+
}
|
|
70
76
|
const { id, baseUrl } = await resolveTarget(target, filePath);
|
|
71
77
|
const backup = await getBackup(baseUrl, id, backupId);
|
|
72
78
|
const note = backup.note ? `, "${backup.note}"` : '';
|
|
73
79
|
console.log(`Backup ${backup.id} from ${formatTime(backup.timestamp)}: ${backup.mapName || 'Untitled'}, ${cards(backup.cardCount)}${note}`);
|
|
74
80
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
theme: { prefix: '>' },
|
|
80
|
-
});
|
|
81
|
-
if (!ok) {
|
|
82
|
-
console.log('Aborted.');
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
81
|
+
const ok = await guard({ message: 'Restore this backup? The map is backed up first.', force: opts.force, stopMessage: 'Nothing was restored.' });
|
|
82
|
+
if (!ok) {
|
|
83
|
+
console.log('Aborted.');
|
|
84
|
+
return;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
const lockPasswordHash = await lockPassword(baseUrl, id);
|
|
@@ -93,5 +93,7 @@ export async function restoreCommand(backupId, target, opts) {
|
|
|
93
93
|
console.log(chalk.green(`Restored map ${id} from backup ${backup.id}`));
|
|
94
94
|
console.log(chalk.dim(`The map before the restore is backup ${safety.id}.`));
|
|
95
95
|
// The saved ETag is left alone, so a push from an old local file stops with a conflict
|
|
96
|
-
|
|
96
|
+
// The local file isn't overwritten, as it may have changes not pushed yet
|
|
97
|
+
const into = target ? 'your local file' : shortPath(filePath);
|
|
98
|
+
console.log(`Run ${chalk.cyan('storymaps pull')} to bring the restored map into ${into}.`);
|
|
97
99
|
}
|
package/lib/commands/diff.js
CHANGED
|
@@ -2,15 +2,20 @@
|
|
|
2
2
|
// storymaps diff — compare local file vs remote or two local files
|
|
3
3
|
|
|
4
4
|
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
5
6
|
import { resolve } from 'node:path';
|
|
6
7
|
import chalk from 'chalk';
|
|
7
8
|
import { diffLines } from 'diff';
|
|
8
9
|
import jsyaml from 'js-yaml';
|
|
9
10
|
import { exportToYaml, yamlObjToJson } from '../core/yaml.js';
|
|
10
11
|
import { detectFormat } from '../util/file.js';
|
|
11
|
-
import { resolveTarget } from '../util/identity.js';
|
|
12
|
+
import { resolveTarget, parseProjectLink } from '../util/identity.js';
|
|
13
|
+
import { StopError } from '../util/prompt.js';
|
|
12
14
|
import { getMap } from '../util/api.js';
|
|
13
15
|
import { printChangeSummary } from '../util/summary.js';
|
|
16
|
+
import { printFullDiff } from '../util/full-diff.js';
|
|
17
|
+
import { findProjectFolder } from '../util/project.js';
|
|
18
|
+
import { diffProject } from './project-sync.js';
|
|
14
19
|
|
|
15
20
|
async function readAndNormalise(filePath) {
|
|
16
21
|
const content = await readFile(filePath, 'utf-8');
|
|
@@ -25,8 +30,52 @@ async function readAndNormalise(filePath) {
|
|
|
25
30
|
return exportToYaml(json);
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
// The local file of the map a link or ID names: its file in the project folder, or storymap.yml
|
|
34
|
+
// when that's the map. A bare project link diffs the whole project folder. Returns null when
|
|
35
|
+
// it has already printed the result or said why it can't.
|
|
36
|
+
async function localFileFor(source, opts) {
|
|
37
|
+
const link = parseProjectLink(source);
|
|
38
|
+
const ctx = await findProjectFolder({ dir: opts.dir }).catch(() => null);
|
|
39
|
+
if (link && !link.mapId) {
|
|
40
|
+
if (ctx?.projectId === link.projectId) {
|
|
41
|
+
await diffProject(ctx, { full: opts.full });
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
throw new StopError(`There's no folder here for that project. Run ${chalk.cyan(`storymaps pull ${source}`)} first.`);
|
|
45
|
+
}
|
|
46
|
+
let id;
|
|
47
|
+
try {
|
|
48
|
+
({ id } = await resolveTarget(source));
|
|
49
|
+
} catch {
|
|
50
|
+
throw new StopError(`File not found: ${source}`);
|
|
51
|
+
}
|
|
52
|
+
const inFolder = ctx?.files.find(f => f.id === id);
|
|
53
|
+
if (inFolder) return inFolder.path;
|
|
54
|
+
const single = resolve('storymap.yml');
|
|
55
|
+
if (existsSync(single)) {
|
|
56
|
+
try {
|
|
57
|
+
if (String(jsyaml.load(await readFile(single, 'utf-8'))?.id) === id) return single;
|
|
58
|
+
} catch { /* not readable, so not this map */ }
|
|
59
|
+
}
|
|
60
|
+
throw new StopError(`There's no local file for that map here. Run ${chalk.cyan(`storymaps pull ${source}`)} to get one.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
28
63
|
export async function diffCommand(source, target, opts) {
|
|
29
|
-
|
|
64
|
+
// No files named in a project folder: every map against the server
|
|
65
|
+
if (!source) {
|
|
66
|
+
const ctx = await findProjectFolder({ dir: opts.dir });
|
|
67
|
+
if (ctx?.projectId) {
|
|
68
|
+
await diffProject(ctx, { full: opts.full });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A link or map ID rather than a file: that map's local file, compared with the server
|
|
74
|
+
let sourcePath = resolve(source || 'storymap.yml');
|
|
75
|
+
if (source && !target && !existsSync(sourcePath)) {
|
|
76
|
+
sourcePath = await localFileFor(source, opts);
|
|
77
|
+
if (!sourcePath) return;
|
|
78
|
+
}
|
|
30
79
|
const sourceYaml = await readAndNormalise(sourcePath);
|
|
31
80
|
|
|
32
81
|
// Show changes from old to new. Two local files go from the first to the second. Against the
|
|
@@ -49,7 +98,12 @@ export async function diffCommand(source, target, opts) {
|
|
|
49
98
|
const json = yamlObjToJson(obj);
|
|
50
99
|
oldYaml = exportToYaml(json);
|
|
51
100
|
newYaml = sourceYaml;
|
|
52
|
-
|
|
101
|
+
// A map in a project shows its project link
|
|
102
|
+
let project;
|
|
103
|
+
try {
|
|
104
|
+
project = jsyaml.load(await readFile(sourcePath, 'utf-8'))?.project;
|
|
105
|
+
} catch { /* JSON or unreadable: the plain link will do */ }
|
|
106
|
+
oldLabel = `remote: ${project ? `${baseUrl}/p/${project}/${id}` : `${baseUrl}/${id}`}`;
|
|
53
107
|
newLabel = `local: ${sourcePath}`;
|
|
54
108
|
}
|
|
55
109
|
|
|
@@ -67,41 +121,7 @@ export async function diffCommand(source, target, opts) {
|
|
|
67
121
|
return;
|
|
68
122
|
}
|
|
69
123
|
|
|
70
|
-
// --full: show line-level diff
|
|
71
|
-
const CONTEXT = 3;
|
|
72
|
-
const lines = [];
|
|
73
|
-
let oldLine = 1;
|
|
74
|
-
let newLine = 1;
|
|
75
|
-
for (const part of changes) {
|
|
76
|
-
for (const text of part.value.replace(/\n$/, '').split('\n')) {
|
|
77
|
-
if (part.added) {
|
|
78
|
-
lines.push({ tag: '+', text, oldNum: null, newNum: newLine++ });
|
|
79
|
-
} else if (part.removed) {
|
|
80
|
-
lines.push({ tag: '-', text, oldNum: oldLine++, newNum: null });
|
|
81
|
-
} else {
|
|
82
|
-
lines.push({ tag: ' ', text, oldNum: oldLine++, newNum: newLine++ });
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const width = String(Math.max(oldLine, newLine)).length;
|
|
88
|
-
const pad = n => n === null ? ' '.repeat(width) : String(n).padStart(width);
|
|
89
|
-
|
|
90
124
|
console.log(chalk.bold(`--- ${oldLabel}`));
|
|
91
125
|
console.log(chalk.bold(`+++ ${newLabel}`));
|
|
92
|
-
|
|
93
|
-
let lastPrinted = -1;
|
|
94
|
-
for (let i = 0; i < lines.length; i++) {
|
|
95
|
-
const nearby = lines.slice(Math.max(0, i - CONTEXT), i + CONTEXT + 1);
|
|
96
|
-
if (!nearby.some(l => l.tag !== ' ')) continue;
|
|
97
|
-
if (lastPrinted !== -1 && i - lastPrinted > 1) {
|
|
98
|
-
console.log(chalk.dim(`${'·'.repeat(width * 2 + 5)}`));
|
|
99
|
-
}
|
|
100
|
-
const { tag, text, oldNum, newNum } = lines[i];
|
|
101
|
-
const num = chalk.dim(`${pad(oldNum)} ${pad(newNum)}`);
|
|
102
|
-
if (tag === '+') console.log(`${num} ${chalk.green(`+ ${text}`)}`);
|
|
103
|
-
else if (tag === '-') console.log(`${num} ${chalk.red(`- ${text}`)}`);
|
|
104
|
-
else console.log(`${num} ${chalk.dim(` ${text}`)}`);
|
|
105
|
-
lastPrinted = i;
|
|
106
|
-
}
|
|
126
|
+
printFullDiff(changes);
|
|
107
127
|
}
|
package/lib/commands/init.js
CHANGED
|
@@ -6,9 +6,10 @@ import { existsSync } from 'node:fs';
|
|
|
6
6
|
import { resolve } from 'node:path';
|
|
7
7
|
import { confirm } from '@inquirer/prompts';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
|
-
import { postMap } from '../util/api.js';
|
|
9
|
+
import { getMap, postMap } from '../util/api.js';
|
|
10
|
+
import { writeEtag } from '../util/file.js';
|
|
10
11
|
|
|
11
|
-
function template(site) {
|
|
12
|
+
export function template(site) {
|
|
12
13
|
const siteLine = site ? `site: ${site}\n` : '';
|
|
13
14
|
return `\
|
|
14
15
|
name: My Story Map
|
|
@@ -75,8 +76,9 @@ export async function initCommand(opts) {
|
|
|
75
76
|
: `https://${site}`;
|
|
76
77
|
|
|
77
78
|
let output = template(site);
|
|
79
|
+
let id = null;
|
|
78
80
|
try {
|
|
79
|
-
|
|
81
|
+
({ id } = await postMap(baseUrl, output, 'text/yaml'));
|
|
80
82
|
output = output.replace(/^(name: My Story Map\n)/, `$1id: ${id}\n`);
|
|
81
83
|
console.log(chalk.green(`Created storymap.yml (map ID: ${id})`));
|
|
82
84
|
console.log(`${chalk.cyan(`${baseUrl}/${id}`)}\n`);
|
|
@@ -86,5 +88,12 @@ export async function initCommand(opts) {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
await writeFile(filePath, output, 'utf-8');
|
|
91
|
+
// The file matches the new map, so the first push from it stops if the map changes in the browser first
|
|
92
|
+
if (id) {
|
|
93
|
+
try {
|
|
94
|
+
const { etag } = await getMap(baseUrl, id);
|
|
95
|
+
await writeEtag(id, filePath, etag);
|
|
96
|
+
} catch { /* non-fatal */ }
|
|
97
|
+
}
|
|
89
98
|
console.log(highlightYaml(output));
|
|
90
99
|
}
|