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.
@@ -0,0 +1,73 @@
1
+ // Storymaps CLI — AGPL-3.0 — see LICENCE for details
2
+ // Questions, and the passwords for locked maps and projects
3
+ //
4
+ // A question is only asked when the CLI runs in a terminal. Run from a script, CI or most agent
5
+ // tools there's no terminal, so:
6
+ // - a question that only picks how much to do takes its default, and the command says so
7
+ // - a question that guards against overwriting or removing something stops, naming --force
8
+ // -y, --yes takes the defaults in a terminal too, for agent tools that do run in one.
9
+ // Passwords are never flags. They're typed, remembered after an unlock, or taken from the
10
+ // STORYMAPS_PASSWORD environment variable, for scripts and CI.
11
+
12
+ import { createHash } from 'node:crypto';
13
+ import { confirm, password, select } from '@inquirer/prompts';
14
+
15
+ const THEME = { prefix: '>' };
16
+
17
+ export const PASSWORD_ENV = 'STORYMAPS_PASSWORD';
18
+
19
+ export const isInteractive = () => !!(process.stdin.isTTY && process.stdout.isTTY);
20
+
21
+ export const hashPassword = (pw) => createHash('sha256').update(pw).digest('hex');
22
+
23
+ // The password from STORYMAPS_PASSWORD, hashed, or null when it isn't set
24
+ export const envPasswordHash = () => {
25
+ const pw = process.env[PASSWORD_ENV];
26
+ return pw ? hashPassword(pw) : null;
27
+ };
28
+
29
+ // Stops a command with a message, without the "Error:" a crash would print
30
+ export class StopError extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = 'StopError';
34
+ }
35
+ }
36
+
37
+ // A question about how much to do. Returns the default with --yes or no terminal.
38
+ export async function choose({ message, choices, defaultValue, yes }) {
39
+ if (yes || !isInteractive()) return defaultValue;
40
+ return select({ message, choices, default: defaultValue, theme: THEME });
41
+ }
42
+
43
+ // A yes or no question about how much to do, with the same rules as choose
44
+ export async function ask({ message, defaultValue = false, yes }) {
45
+ if (yes || !isInteractive()) return defaultValue;
46
+ return confirm({ message, default: defaultValue, theme: THEME });
47
+ }
48
+
49
+ // A question that guards against overwriting or removing something. --force answers yes; with
50
+ // no terminal and no --force, the command stops and says to use it.
51
+ export async function guard({ message, force, stopMessage }) {
52
+ if (force) return true;
53
+ if (!isInteractive()) throw new StopError(`${stopMessage || 'Nothing was changed.'} Run again with --force to go ahead without being asked.`);
54
+ return confirm({ message, default: true, theme: THEME });
55
+ }
56
+
57
+ // A password, hashed: from STORYMAPS_PASSWORD when allowed and set, or typed in a terminal.
58
+ // confirmTwice asks again, for setting a new password. Throws a StopError when neither is possible.
59
+ export async function askPassword({ allowEnv = true, confirmTwice = false, noPasswordMessage } = {}) {
60
+ if (allowEnv) {
61
+ const fromEnv = envPasswordHash();
62
+ if (fromEnv) return fromEnv;
63
+ }
64
+ if (!isInteractive()) {
65
+ throw new StopError(noPasswordMessage || `This needs a password, and there's no terminal to type it in. Set ${PASSWORD_ENV} for this command.`);
66
+ }
67
+ const pw = await password({ message: 'Password:', theme: THEME });
68
+ if (confirmTwice) {
69
+ const again = await password({ message: 'Confirm password:', theme: THEME });
70
+ if (pw !== again) throw new StopError('Passwords do not match.');
71
+ }
72
+ return hashPassword(pw);
73
+ }
@@ -11,7 +11,8 @@ const FIELDS = ['body', 'color', 'url', 'status', 'points', 'tags'];
11
11
  const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
12
12
 
13
13
  // Slice names can span lines (e.g. "V1:\nBasic House"); keep each summary entry on one line
14
- const oneLine = (s) => String(s ?? '').replace(/\s*\n\s*/g, ' ');
14
+ // A name on one line, or (no name) for an unnamed step or slice, which would otherwise print blank
15
+ const oneLine = (s) => String(s ?? '').replace(/\s*\n\s*/g, ' ').trim() || '(no name)';
15
16
 
16
17
  const changedFields = (a, b) =>
17
18
  FIELDS.filter(f => JSON.stringify(a?.[f] ?? null) !== JSON.stringify(b?.[f] ?? null));
@@ -139,8 +140,8 @@ export function changeSummary(oldYaml, newYaml) {
139
140
  const total = newSteps.length - oldSteps.length;
140
141
  const delta = total > 0 ? chalk.green(`+${total}`) : total < 0 ? chalk.red(`${total}`) : chalk.dim('±0');
141
142
  lines.push(` steps: ${delta}`);
142
- for (const s of addedSteps) lines.push(chalk.green(` + ${s}`));
143
- for (const s of removedSteps) lines.push(chalk.red(` - ${s}`));
143
+ for (const s of addedSteps) lines.push(chalk.green(` + ${oneLine(s)}`));
144
+ for (const s of removedSteps) lines.push(chalk.red(` - ${oneLine(s)}`));
144
145
  }
145
146
  if (reordered(oldSteps, newSteps)) lines.push(chalk.yellow(' ~ steps reordered'));
146
147
  const stepFields = new Set();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storymaps",
3
- "version": "1.3.4",
3
+ "version": "1.4.1",
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": {