storymaps 1.3.3 → 1.3.5

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 CHANGED
@@ -180,7 +180,9 @@ storymaps backup -m "Before the workshop" # with a note
180
180
  storymaps backup storymaps.io/abc123 # back up a map you haven't pulled
181
181
  ```
182
182
 
183
- The backup shows in the map's activity log, and in **Menu → Backups** in the app.
183
+ The backup shows in the map's activity log, and in **Menu → Backups** in the app. If the map hasn't changed since one of its backups, no new backup is made, and the CLI says which backup matches.
184
+
185
+ **Locked maps** use the password saved by `storymaps unlock`, or ask for it.
184
186
 
185
187
  ### `backups`
186
188
 
@@ -209,7 +211,8 @@ storymaps restore 6e40f124ae7e --force # restores without asking
209
211
  ### About backups
210
212
 
211
213
  - The CLI and the app share the same backups. A backup made in the app shows in `storymaps backups`, and one made with the CLI shows in the app.
212
- - The server keeps the newest 8 backups of each map.
214
+ - The server keeps the newest 8 backups of each map. Backups are protected from deletion, but can be removed if needed by contacting us.
215
+ - Making a backup of a locked map needs its password, so nobody else can push its backups out.
213
216
  - A self-hosted server needs updating before the CLI can make backups on it. An older one turns the request away, and the CLI says so.
214
217
 
215
218
  ### `validate`
package/bin/storymaps.js CHANGED
@@ -24,7 +24,7 @@ const program = new Command();
24
24
 
25
25
  program
26
26
  .name('storymaps')
27
- .description('CLI companion for storymaps.io — manage user story maps from the terminal')
27
+ .description('CLI companion for storymaps.io: manage user story maps from the terminal')
28
28
  .version(pkg.version)
29
29
  .showHelpAfterError()
30
30
  .addHelpText('after', '\nRun storymaps <command> --help for details on a specific command.');
@@ -14,7 +14,12 @@ const cards = (n) => `${n} card${n === 1 ? '' : 's'}`;
14
14
 
15
15
  export async function backupCommand(target, opts) {
16
16
  const { id, baseUrl } = await resolveTarget(target, resolve(opts.file || 'storymap.yml'));
17
- const backup = await createBackup(baseUrl, id, opts.message || '');
17
+ const lockPasswordHash = await lockPassword(baseUrl, id);
18
+ const backup = await createBackup(baseUrl, id, opts.message || '', { lockPasswordHash });
19
+ if (backup.unchanged) {
20
+ console.log(`No changes since backup ${chalk.cyan(backup.id)} from ${formatTime(backup.timestamp)}, so no new backup was made.`);
21
+ return;
22
+ }
18
23
  console.log(chalk.green(`Backed up map ${id} as ${backup.id} (${cards(backup.cardCount)})`));
19
24
  }
20
25
 
@@ -82,7 +87,7 @@ export async function restoreCommand(backupId, target, opts) {
82
87
  const lockPasswordHash = await lockPassword(baseUrl, id);
83
88
 
84
89
  // 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');
90
+ const safety = await createBackup(baseUrl, id, 'Auto: before restore', { lockPasswordHash });
86
91
  await putMap(baseUrl, id, backup.data, { contentType: 'application/json', lockPasswordHash, restoredBackup: backup.id });
87
92
 
88
93
  console.log(chalk.green(`Restored map ${id} from backup ${backup.id}`));
@@ -6,7 +6,8 @@ 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
12
  function template(site) {
12
13
  const siteLine = site ? `site: ${site}\n` : '';
@@ -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
- const { id } = await postMap(baseUrl, output, 'text/yaml');
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
  }
@@ -14,7 +14,7 @@ export async function lockCommand(target, opts) {
14
14
  try {
15
15
  ({ id, baseUrl } = await resolveTarget(target, filePath));
16
16
  } catch {
17
- console.error(chalk.red('No map ID found. Run "storymaps new" first to reserve an ID, or pass one directly: storymaps lock <id>'));
17
+ console.error(chalk.red('No map ID found. Run "storymaps push" to create it on the server, or pass one directly: storymaps lock <id>'));
18
18
  process.exitCode = 1;
19
19
  return;
20
20
  }
@@ -8,7 +8,7 @@ import { confirm } from '@inquirer/prompts';
8
8
  import chalk from 'chalk';
9
9
  import jsyaml from 'js-yaml';
10
10
  import { exportToYaml, yamlObjToJson } from '../core/yaml.js';
11
- import { detectFormat, writeCache } from '../util/file.js';
11
+ import { detectFormat, writeEtag } from '../util/file.js';
12
12
  import { resolveTarget } from '../util/identity.js';
13
13
  import { getMap } from '../util/api.js';
14
14
  import { printChangeSummary } from '../util/summary.js';
@@ -32,7 +32,7 @@ export async function pullCommand(target, opts) {
32
32
 
33
33
  // Quick check: if raw content is identical, nothing to do
34
34
  if (oldContent === data) {
35
- if (etag) await writeCache(id, { etag });
35
+ await writeEtag(id, filePath, etag);
36
36
  console.log(chalk.dim('Already up to date.'));
37
37
  return;
38
38
  }
@@ -64,8 +64,6 @@ export async function pullCommand(target, opts) {
64
64
  }
65
65
 
66
66
  await writeFile(filePath, data, 'utf-8');
67
- if (etag) {
68
- await writeCache(id, { etag });
69
- }
67
+ await writeEtag(id, filePath, etag);
70
68
  console.log(chalk.green(`Pulled map ${id} → ${filePath}`));
71
69
  }
@@ -11,7 +11,16 @@ import { exportToYaml, importFromYaml, yamlObjToJson } from '../core/yaml.js';
11
11
  import { printChangeSummary } from '../util/summary.js';
12
12
  import { resolveTarget } from '../util/identity.js';
13
13
  import { getMap, putMap, postMap, ApiError } from '../util/api.js';
14
- import { detectFormat, readCache, writeCache } from '../util/file.js';
14
+ import { detectFormat, readCache, readEtag, writeEtag } from '../util/file.js';
15
+
16
+ // The map as it is on the server now is what this file matches, so a later push from it stops if
17
+ // the map changes in the meantime
18
+ async function rememberEtag(baseUrl, id, filePath) {
19
+ try {
20
+ const { etag } = await getMap(baseUrl, id);
21
+ await writeEtag(id, filePath, etag);
22
+ } catch { /* non-fatal */ }
23
+ }
15
24
 
16
25
  function siteToBaseUrl(site) {
17
26
  if (!site) return 'https://storymaps.io';
@@ -48,7 +57,7 @@ export async function pushCommand(target, opts) {
48
57
  try {
49
58
  ({ id, baseUrl } = await resolveTarget(target, filePath));
50
59
  } catch {
51
- // No ID in file — create a new map on the server
60
+ // No ID in the file: create a new map on the server
52
61
  const obj = jsyaml.load(content);
53
62
  baseUrl = siteToBaseUrl(obj?.site);
54
63
  const contentType = format === 'json' ? 'application/json' : 'text/yaml';
@@ -63,6 +72,7 @@ export async function pushCommand(target, opts) {
63
72
  updated = `id: ${id}\n${content}`;
64
73
  }
65
74
  await writeFile(filePath, updated, 'utf-8');
75
+ await rememberEtag(baseUrl, id, filePath);
66
76
 
67
77
  const url = `${baseUrl}/${id}`;
68
78
  console.log(chalk.green(`Created new map: ${url}`));
@@ -79,15 +89,14 @@ export async function pushCommand(target, opts) {
79
89
  if (remoteNorm !== localNorm) printChangeSummary(remoteNorm, localNorm);
80
90
  } catch { /* non-fatal */ }
81
91
 
82
- const cache = await readCache(id);
83
- let etag = opts.force ? undefined : cache.etag;
84
- let lockPasswordHash = cache.hash;
92
+ let etag = opts.force ? undefined : await readEtag(id, filePath);
93
+ let lockPasswordHash = (await readCache(id)).hash;
85
94
 
86
95
  try {
87
96
  await putMap(baseUrl, id, content, { etag, contentType, lockPasswordHash });
88
97
  } catch (err) {
89
98
  if (err instanceof ApiError && err.conflict) {
90
- console.error(chalk.red('Conflict — the remote map has changed since you last pulled.'));
99
+ console.error(chalk.red('Conflict: the remote map has changed since you last pulled.'));
91
100
  console.error(`Run ${chalk.cyan('storymaps pull')} to update, or use ${chalk.cyan('--force')} to overwrite.`);
92
101
  process.exit(1);
93
102
  }
@@ -109,13 +118,8 @@ export async function pushCommand(target, opts) {
109
118
  }
110
119
  }
111
120
 
112
- // Refresh saved etag so the next push is also protected
113
- try {
114
- const remote = await getMap(baseUrl, id);
115
- if (remote.etag) {
116
- await writeCache(id, { etag: remote.etag });
117
- }
118
- } catch { /* non-fatal */ }
121
+ // Refresh this file's ETag so the next push from it is also protected
122
+ await rememberEtag(baseUrl, id, filePath);
119
123
 
120
124
  const url = `${baseUrl}/${id}`;
121
125
  console.log(chalk.green(`Pushed to ${url}`));
@@ -127,7 +127,7 @@ export async function statusCommand(target, opts) {
127
127
  const total = cards.length;
128
128
  const label = (slice.name || '(unnamed)').padEnd(maxName);
129
129
  if (total === 0) {
130
- console.log(` ${chalk.dim(label)} — empty`);
130
+ console.log(` ${chalk.dim(label)} (empty)`);
131
131
  continue;
132
132
  }
133
133
  const done = cards.filter(c => c.status === 'done').length;
@@ -162,7 +162,7 @@ export async function statusCommand(target, opts) {
162
162
  if (byStatus.blocked.length > 0) {
163
163
  console.log(chalk.red.bold('Blocked'));
164
164
  for (const story of byStatus.blocked) {
165
- console.log(` ${chalk.red('●')} ${story.name}${story.body ? chalk.dim(` — ${story.body.split('\n')[0]}`) : ''}`);
165
+ console.log(` ${chalk.red('●')} ${story.name}${story.body ? chalk.dim(`: ${story.body.split('\n')[0]}`) : ''}`);
166
166
  }
167
167
  console.log();
168
168
  }
@@ -15,7 +15,7 @@ export async function unlockCommand(target, opts) {
15
15
  try {
16
16
  ({ id, baseUrl } = await resolveTarget(target, filePath));
17
17
  } catch {
18
- console.error(chalk.red('No map ID found. Run "storymaps new" first to reserve an ID, or pass one directly: storymaps unlock <id>'));
18
+ console.error(chalk.red('No map ID found. Run "storymaps push" to create it on the server, or pass one directly: storymaps unlock <id>'));
19
19
  process.exitCode = 1;
20
20
  return;
21
21
  }
package/lib/core/yaml.js CHANGED
@@ -198,9 +198,24 @@ export function jsonToYamlObj(data) {
198
198
  // Import: YAML string → JSON (compatible with deserializeV1)
199
199
  // ============================================================================
200
200
 
201
+ // YAML aliases let a few hundred bytes stand for millions of values (an "alias bomb"), which
202
+ // would run the server out of memory once read. Count the values, an alias each time it is
203
+ // used, and stop well above the size of any real story map.
204
+ const MAX_YAML_VALUES = 1_000_000;
205
+ const checkYamlSize = (root) => {
206
+ let count = 0;
207
+ const stack = [root];
208
+ while (stack.length) {
209
+ const value = stack.pop();
210
+ if (++count > MAX_YAML_VALUES) throw new Error('YAML is too large');
211
+ if (value && typeof value === 'object') for (const child of Object.values(value)) stack.push(child);
212
+ }
213
+ };
214
+
201
215
  export function importFromYaml(yamlString) {
202
216
  const obj = jsyaml.load(yamlString);
203
217
  if (!obj || typeof obj !== 'object') throw new Error('Invalid YAML');
218
+ checkYamlSize(obj);
204
219
  const validation = validateYaml(obj);
205
220
  if (!validation.valid) {
206
221
  const err = new Error(validation.errors.join('\n'));
package/lib/util/api.js CHANGED
@@ -32,9 +32,9 @@ async function handleResponse(res) {
32
32
  const body = errorMessage(await res.text().catch(() => ''));
33
33
  switch (res.status) {
34
34
  case 404: throw new ApiError('Map not found', 404);
35
- case 409: throw new ApiError(body || 'Conflict — the remote map has changed. Pull first or use --force.', 409, { conflict: true });
35
+ case 409: throw new ApiError(body || 'Conflict: the remote map has changed. Pull first or use --force.', 409, { conflict: true });
36
36
  case 423: throw new ApiError('Map is locked', 423);
37
- case 429: throw new ApiError('Rate limited — please wait and try again', 429);
37
+ case 429: throw new ApiError('Rate limited. Please wait and try again.', 429);
38
38
  default: throw new ApiError(body || `Request failed (${res.status})`, res.status);
39
39
  }
40
40
  }
@@ -135,10 +135,13 @@ export async function getBackup(baseUrl, id, backupId) {
135
135
  return res.json();
136
136
  }
137
137
 
138
- export async function createBackup(baseUrl, id, note = '') {
138
+ // Answers with the new backup, or with a kept backup marked unchanged when the map matches it
139
+ export async function createBackup(baseUrl, id, note = '', { lockPasswordHash } = {}) {
140
+ const headers = { 'Content-Type': 'application/json' };
141
+ if (lockPasswordHash) headers['X-Lock-Password'] = lockPasswordHash;
139
142
  const res = await request(`${baseUrl}/api/backups/${id}`, {
140
143
  method: 'POST',
141
- headers: { 'Content-Type': 'application/json' },
144
+ headers,
142
145
  body: JSON.stringify({ note }),
143
146
  });
144
147
  // Servers from before the CLI could make backups turn the request away
package/lib/util/file.js CHANGED
@@ -32,6 +32,21 @@ export async function writeCache(mapId, updates) {
32
32
  await writeFile(cachePathFor(mapId), JSON.stringify(cache), 'utf-8');
33
33
  }
34
34
 
35
+ // The ETag of the map as each file last pulled or pushed it, by the file's full path. Two copies of
36
+ // one map on the same computer each keep their own, so a push from an older copy stops with a
37
+ // conflict instead of overwriting what the other pushed. Versions up to 1.3.4 kept one ETag per map;
38
+ // it's still read for files pulled with them, and no longer written.
39
+ export async function readEtag(mapId, filePath) {
40
+ const cache = await readCache(mapId);
41
+ return cache.files?.[filePath] ?? cache.etag;
42
+ }
43
+
44
+ export async function writeEtag(mapId, filePath, etag) {
45
+ if (!etag) return;
46
+ const cache = await readCache(mapId);
47
+ await writeCache(mapId, { files: { ...cache.files, [filePath]: etag } });
48
+ }
49
+
35
50
 
36
51
  export function detectFormat(filePath) {
37
52
  const ext = extname(filePath).toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storymaps",
3
- "version": "1.3.3",
3
+ "version": "1.3.5",
4
4
  "description": "CLI tool for storymaps.io - import, export, validate, and manage user story maps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {