storymaps 1.3.4 → 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/bin/storymaps.js +1 -1
- package/lib/commands/init.js +11 -2
- package/lib/commands/lock.js +1 -1
- package/lib/commands/pull.js +3 -5
- package/lib/commands/push.js +17 -13
- package/lib/commands/status.js +2 -2
- package/lib/commands/unlock.js +1 -1
- package/lib/core/yaml.js +15 -0
- package/lib/util/api.js +2 -2
- package/lib/util/file.js +15 -0
- package/package.json +1 -1
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
|
|
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.');
|
package/lib/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/commands/lock.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/lib/commands/pull.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
await writeCache(id, { etag });
|
|
69
|
-
}
|
|
67
|
+
await writeEtag(id, filePath, etag);
|
|
70
68
|
console.log(chalk.green(`Pulled map ${id} → ${filePath}`));
|
|
71
69
|
}
|
package/lib/commands/push.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
83
|
-
let
|
|
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
|
|
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
|
|
113
|
-
|
|
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}`));
|
package/lib/commands/status.js
CHANGED
|
@@ -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)}
|
|
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(
|
|
165
|
+
console.log(` ${chalk.red('●')} ${story.name}${story.body ? chalk.dim(`: ${story.body.split('\n')[0]}`) : ''}`);
|
|
166
166
|
}
|
|
167
167
|
console.log();
|
|
168
168
|
}
|
package/lib/commands/unlock.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}
|
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();
|