storymaps 1.2.4 → 1.2.6

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
@@ -257,6 +257,8 @@ slices:
257
257
 
258
258
  Cards support: `name`, `body`, `color`, `status` (done / in-progress / planned / blocked), `points`, `tags`, and `url`.
259
259
 
260
+ Slices support: `name`, `stories`, `goal` (up to 200 characters), `targetDate` (`YYYY-MM-DD`), and `collapsed` with `closedReason` (complete / closed).
261
+
260
262
  ## Self-Hosting
261
263
 
262
264
  The CLI works with any storymaps.io instance:
@@ -267,6 +269,36 @@ storymaps init --site storymaps.yourcompany.com
267
269
 
268
270
  See the [main project README](https://github.com/jackgleeson/storymaps.io#self-hosting) for setup instructions.
269
271
 
272
+ ## Development
273
+
274
+ ```bash
275
+ npm install
276
+ npm test
277
+ ```
278
+
279
+ `npm test` runs the unit tests in `test/unit` with Node's built-in test runner (Node 22 or later). They cover the YAML format, the API client, the change summary, and how a target resolves to a map, with no server needed. CI runs them on Node 22 and 24 for every push to `main` and every pull request.
280
+
281
+ To try a change against a local storymaps.io server, run the CLI from the repo, for example `node bin/storymaps.js pull localhost/<map-id>`.
282
+
283
+ ### Releasing
284
+
285
+ 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.
287
+ 3. Tag the commit and push both:
288
+ ```bash
289
+ git tag vx.y.z
290
+ git push origin main vx.y.z
291
+ ```
292
+ 4. Check you're logged in to npm, then publish:
293
+ ```bash
294
+ npm whoami # if this fails, run npm login
295
+ npm pack --dry-run # optional: list what will be published
296
+ npm publish
297
+ ```
298
+ 5. Confirm the new version is live with `npm view storymaps version`.
299
+
300
+ Only `bin/`, `lib/`, the README and the licence are published (see `files` in `package.json`).
301
+
270
302
  ## Licence
271
303
 
272
304
  AGPL-3.0 - see [LICENCE](LICENCE) for details.
@@ -36,11 +36,6 @@ slices:
36
36
  stories:
37
37
  New Step:
38
38
  - name: New Task or Detail
39
- notes: >-
40
- Thanks for trying Storymaps.io! If you find it useful, please consider starring the open-source repo:
41
- https://github.com/jackgleeson/storymaps.io
42
-
43
- Have a great day!
44
39
  `;
45
40
  }
46
41
 
@@ -83,7 +78,8 @@ export async function initCommand(opts) {
83
78
  try {
84
79
  const { id } = await postMap(baseUrl, output, 'text/yaml');
85
80
  output = output.replace(/^(name: My Story Map\n)/, `$1id: ${id}\n`);
86
- console.log(chalk.green(`Created storymap.yml (map ID: ${id})\n`));
81
+ console.log(chalk.green(`Created storymap.yml (map ID: ${id})`));
82
+ console.log(`${chalk.cyan(`${baseUrl}/${id}`)}\n`);
87
83
  } catch {
88
84
  console.log(chalk.green('Created storymap.yml\n'));
89
85
  console.log(chalk.dim('Tip: run "storymaps push" to upload the map when you\'re ready to sync.\n'));
@@ -21,6 +21,28 @@ export const isValidUrl = (url) => {
21
21
  }
22
22
  };
23
23
 
24
+ // Slice goal and target date are optional. The date is stored as YYYY-MM-DD; YAML can parse an
25
+ // unquoted date as a Date, so accept that too. Anything else is dropped.
26
+ export const SLICE_GOAL_MAX_LENGTH = 200;
27
+
28
+ export const normalizeSliceGoal = (value) =>
29
+ typeof value === 'string' ? value.trim().slice(0, SLICE_GOAL_MAX_LENGTH) : '';
30
+
31
+ export const normalizeSliceDate = (value) => {
32
+ if (value instanceof Date && !isNaN(value)) return value.toISOString().slice(0, 10);
33
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return '';
34
+ return isNaN(new Date(`${value}T00:00:00Z`)) ? '' : value;
35
+ };
36
+
37
+ // Copies a slice's goal and target date from one object to another, leaving out empty values
38
+ export const copySliceDetails = (from, to) => {
39
+ const goal = normalizeSliceGoal(from.goal);
40
+ const targetDate = normalizeSliceDate(from.targetDate);
41
+ if (goal) to.goal = goal;
42
+ if (targetDate) to.targetDate = targetDate;
43
+ return to;
44
+ };
45
+
24
46
  export const CARD_COLORS = {
25
47
  red: '#fca5a5',
26
48
  rose: '#fecdd3',
package/lib/core/yaml.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // YAML ↔ JSON transform + validation
3
3
 
4
4
  import jsyaml from 'js-yaml';
5
- import { generateId } from './constants.js';
5
+ import { generateId, copySliceDetails } from './constants.js';
6
6
 
7
7
  const DEFAULT_STEP_COLOR = '#86efac';
8
8
  const DEFAULT_STORY_COLOR = '#fef08a';
@@ -54,10 +54,12 @@ export function jsonToYamlObj(data) {
54
54
  partialSlugMap[pm.id] = slugify(pm.name) || pm.id;
55
55
  });
56
56
 
57
- // Classify each step
58
- const stepTypes = steps.map(step => {
57
+ // Classify each step. A hidden step with a name or cards is written as a normal step so its
58
+ // cards survive (only empty, unnamed hidden steps are spacers).
59
+ const hasCards = (i) => !!(usersArr[i]?.length || activitiesArr[i]?.length || slices.some(s => s.stories?.[i]?.length));
60
+ const stepTypes = steps.map((step, i) => {
59
61
  if (step.partialMapId) return 'partial';
60
- if (step.hidden) return 'spacer';
62
+ if (step.hidden && !step.name && !hasCards(i)) return 'spacer';
61
63
  return 'step';
62
64
  });
63
65
 
@@ -70,41 +72,17 @@ export function jsonToYamlObj(data) {
70
72
  });
71
73
  const duplicateNames = new Set(Object.keys(nameCounts).filter(n => nameCounts[n] > 1));
72
74
 
73
- // Resolve names — namespace duplicates with nearest activity/user
75
+ // Number duplicate names (Confirm[0], Confirm[1]) so cards point at the right step.
76
+ // Import strips the number, so the names come back unchanged.
77
+ const counters = {};
74
78
  const resolvedNames = steps.map((step, i) => {
75
79
  if (stepTypes[i] !== 'step') return null;
76
80
  const name = step.name || '';
77
81
  if (!duplicateNames.has(name)) return name;
78
- for (let j = i; j >= 0; j--) {
79
- if (activitiesArr[j]?.length) return `${activitiesArr[j][0].name}: ${name}`;
80
- }
81
- for (let j = i; j >= 0; j--) {
82
- if (usersArr[j]?.length) return `${usersArr[j][0].name}: ${name}`;
83
- }
84
- return name;
85
- });
86
-
87
- // Second pass: add :[N] suffix to any still-duplicate resolved names
88
- const resolvedCounts = {};
89
- resolvedNames.forEach(n => {
90
- if (n != null) resolvedCounts[n] = (resolvedCounts[n] || 0) + 1;
82
+ const idx = counters[name] || 0;
83
+ counters[name] = idx + 1;
84
+ return `${name}[${idx}]`;
91
85
  });
92
- const stillDuplicate = new Set(Object.keys(resolvedCounts).filter(n => resolvedCounts[n] > 1));
93
- if (stillDuplicate.size) {
94
- const counters = {};
95
- for (let i = 0; i < resolvedNames.length; i++) {
96
- const n = resolvedNames[i];
97
- if (n == null || !stillDuplicate.has(n)) continue;
98
- const idx = counters[n] || 0;
99
- counters[n] = idx + 1;
100
- const colonPos = n.indexOf(': ');
101
- if (colonPos >= 0) {
102
- resolvedNames[i] = n.slice(0, colonPos + 1) + `[${idx}]` + n.slice(colonPos + 1);
103
- } else {
104
- resolvedNames[i] = `${n}[${idx}]`;
105
- }
106
- }
107
- }
108
86
 
109
87
  // Build YAML object
110
88
  const result = {};
@@ -177,14 +155,14 @@ export function jsonToYamlObj(data) {
177
155
  const hasExtra = (step.color && step.color !== DEFAULT_STEP_COLOR)
178
156
  || step.body || step.url || step.status || step.points != null || step.tags?.length;
179
157
  if (!hasExtra) return name;
180
- const obj = { name };
181
- if (step.color && step.color !== DEFAULT_STEP_COLOR) obj.color = step.color;
182
- if (step.body) obj.body = step.body;
183
- if (step.url) obj.url = step.url;
184
- if (step.status) obj.status = step.status;
185
- if (step.points != null) obj.points = step.points;
186
- if (step.tags?.length) obj.tags = [...step.tags];
187
- return obj;
158
+ const s = { name };
159
+ if (step.color && step.color !== DEFAULT_STEP_COLOR) s.color = step.color;
160
+ if (step.body) s.body = step.body;
161
+ if (step.url) s.url = step.url;
162
+ if (step.status) s.status = step.status;
163
+ if (step.points != null) s.points = step.points;
164
+ if (step.tags?.length) s.tags = step.tags;
165
+ return s;
188
166
  });
189
167
 
190
168
  // Legend
@@ -196,6 +174,7 @@ export function jsonToYamlObj(data) {
196
174
  const obj = { name: slice.name || '' };
197
175
  if (slice.collapsed) obj.collapsed = true;
198
176
  if (slice.closedReason) obj.closedReason = slice.closedReason;
177
+ copySliceDetails(slice, obj);
199
178
  const stories = {};
200
179
  (slice.stories || []).forEach((cards, i) => {
201
180
  if (!cards?.length) return;
@@ -273,6 +252,7 @@ export function validateYaml(obj) {
273
252
  const label = slice.name || `#${si}`;
274
253
  if (slice.stories) {
275
254
  for (const [stepName, cards] of Object.entries(slice.stories)) {
255
+ if (!stepName) continue;
276
256
  if (!stepNameSet.has(stepName)) {
277
257
  errors.push(`Slice "${label}" references unknown step "${stepName}"`);
278
258
  }
@@ -336,7 +316,7 @@ export function yamlObjToJson(obj) {
336
316
  if (step.url) s.url = step.url;
337
317
  if (step.status) s.status = step.status;
338
318
  if (step.points != null) s.points = step.points;
339
- if (step.tags?.length) s.tags = [...step.tags];
319
+ if (step.tags?.length) s.tags = step.tags;
340
320
  }
341
321
  jsonSteps.push(s);
342
322
  });
@@ -368,6 +348,7 @@ export function yamlObjToJson(obj) {
368
348
  const s = { name: slice.name || '', stories };
369
349
  if (slice.collapsed) s.collapsed = true;
370
350
  if (slice.closedReason) s.closedReason = slice.closedReason;
351
+ copySliceDetails(slice, s);
371
352
  return s;
372
353
  });
373
354
 
@@ -426,7 +407,8 @@ export function yamlObjToJson(obj) {
426
407
  };
427
408
 
428
409
  if (obj.legend?.length) result.legend = obj.legend;
429
- if (obj.notes) result.notes = obj.notes;
410
+ // Keep empty notes so a push with notes: "" clears them (no notes key leaves them alone)
411
+ if (typeof obj.notes === 'string') result.notes = obj.notes;
430
412
  if (jsonPartialMaps.length) result.partialMaps = jsonPartialMaps;
431
413
  if (obj.backups?.length) result.backups = obj.backups;
432
414
  return result;
@@ -446,6 +428,10 @@ function backboneToYaml(positionalArr, namesByIndex) {
446
428
  const obj = { name: card.name };
447
429
  if (card.body) obj.body = card.body;
448
430
  if (card.color) obj.color = card.color;
431
+ if (card.status) obj.status = card.status;
432
+ if (card.points != null) obj.points = card.points;
433
+ if (card.tags?.length) obj.tags = [...card.tags];
434
+ if (card.url) obj.url = card.url;
449
435
  obj.step = stepName;
450
436
  result.push(obj);
451
437
  });
package/lib/util/api.js CHANGED
@@ -10,9 +10,18 @@ class ApiError extends Error {
10
10
  }
11
11
  }
12
12
 
13
+ // The server sends errors as JSON ({"error": "..."}), so show just the message
14
+ function errorMessage(body) {
15
+ try {
16
+ return JSON.parse(body).error || body;
17
+ } catch {
18
+ return body;
19
+ }
20
+ }
21
+
13
22
  async function handleResponse(res) {
14
23
  if (res.ok) return;
15
- const body = await res.text().catch(() => '');
24
+ const body = errorMessage(await res.text().catch(() => ''));
16
25
  switch (res.status) {
17
26
  case 404: throw new ApiError('Map not found', 404);
18
27
  case 409: throw new ApiError(body || 'Conflict — the remote map has changed. Pull first or use --force.', 409, { conflict: true });
@@ -3,6 +3,7 @@
3
3
 
4
4
  import chalk from 'chalk';
5
5
  import jsyaml from 'js-yaml';
6
+ import { normalizeSliceGoal, normalizeSliceDate } from '../core/constants.js';
6
7
 
7
8
  // Fields compared on cards and steps that exist on both sides
8
9
  const FIELDS = ['body', 'color', 'url', 'status', 'points', 'tags'];
@@ -187,6 +188,16 @@ export function changeSummary(oldYaml, newYaml) {
187
188
  const state = !slice.collapsed ? 'reopened' : slice.closedReason === 'closed' ? 'closed' : 'completed';
188
189
  lines.push(chalk.yellow(` ~ slice: ${oneLine(slice.name)} ${state}`));
189
190
  }
191
+ const [oldGoal, newGoal] = [normalizeSliceGoal(before.goal), normalizeSliceGoal(slice.goal)];
192
+ if (oldGoal !== newGoal) {
193
+ const change = !oldGoal ? 'set' : !newGoal ? 'removed' : 'changed';
194
+ lines.push(chalk.yellow(` ~ slice: ${oneLine(slice.name)} goal ${change}`));
195
+ }
196
+ const [oldDate, newDate] = [normalizeSliceDate(before.targetDate), normalizeSliceDate(slice.targetDate)];
197
+ if (oldDate !== newDate) {
198
+ const change = !oldDate ? `set to ${newDate}` : !newDate ? 'removed' : `moved to ${newDate}`;
199
+ lines.push(chalk.yellow(` ~ slice: ${oneLine(slice.name)} target date ${change}`));
200
+ }
190
201
  }
191
202
 
192
203
  // Cards: users, activities and stories in every slice
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "storymaps",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
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": {
7
7
  "storymaps": "./bin/storymaps.js"
8
8
  },
9
+ "scripts": {
10
+ "test": "node --test 'test/unit/**/*.test.js'",
11
+ "test:integration": "node --test 'test/integration/**/*.test.js'"
12
+ },
9
13
  "keywords": [
10
14
  "story-mapping",
11
15
  "user-story",