storymaps 1.3.5 → 1.4.2

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,291 @@
1
+ // Storymaps CLI — AGPL-3.0 — see LICENCE for details
2
+ // storymaps project init, add, lock and unlock: the commands that change a project itself.
3
+ // Renaming a project, and reordering, removing and deleting its maps, stay in the app.
4
+
5
+ import { readFile } from 'node:fs/promises';
6
+ import { resolve } from 'node:path';
7
+ import chalk from 'chalk';
8
+ import jsyaml from 'js-yaml';
9
+ import { importFromYaml } from '../core/yaml.js';
10
+ import { generateId } from '../core/constants.js';
11
+ import {
12
+ getMap, postMap, getProject, createProject, addProjectMap, mapExists, lockProject,
13
+ removeProjectLock, checkProjectPassword, ApiError,
14
+ } from '../util/api.js';
15
+ import { writeEtag, writeCache } from '../util/file.js';
16
+ import { resolveTarget, parseProjectLink, siteToBaseUrl } from '../util/identity.js';
17
+ import {
18
+ PROJECT_DIR, defaultProjectDir, findProjectFolder, readProjectFiles, mapFilePath, shortPath, setIdKey, setProjectKey,
19
+ writeMapFile, noteNewMap, noteDraft, setSiteKey, siteOfBaseUrl, updateLockedInFolder,
20
+ } from '../util/project.js';
21
+ import { projectPassword, rememberProjectPassword, forgetProjectPassword, listNames } from '../util/passwords.js';
22
+ import { askPassword, choose, ask, StopError, PASSWORD_ENV } from '../util/prompt.js';
23
+ import { projectLink, reportLock } from './project-sync.js';
24
+ import { template } from './init.js';
25
+
26
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
27
+ const NEW_MAP_WAIT = 61_000;
28
+
29
+ // Makes a map on the server, waiting a minute if the limit of 10 new maps a minute is reached
30
+ async function createMap(baseUrl, content) {
31
+ try {
32
+ return (await postMap(baseUrl, content, 'text/yaml')).id;
33
+ } catch (err) {
34
+ if (!(err instanceof ApiError && err.status === 429)) throw err;
35
+ console.log(chalk.dim('The server makes 10 new maps a minute, so this waits a minute...'));
36
+ await new Promise(r => setTimeout(r, NEW_MAP_WAIT));
37
+ return (await postMap(baseUrl, content, 'text/yaml')).id;
38
+ }
39
+ }
40
+
41
+ // Writes a map in the project to the folder, as the server exports it with its project key
42
+ async function writeFromServer(baseUrl, projectId, mapId, dir) {
43
+ const path = mapFilePath(dir, mapId);
44
+ const { data, etag } = await getMap(baseUrl, mapId, 'yaml', { project: projectId });
45
+ await writeMapFile(path, data);
46
+ await writeEtag(mapId, path, etag);
47
+ return path;
48
+ }
49
+
50
+ const readMapFile = async (file) => {
51
+ const content = await readFile(resolve(file), 'utf-8');
52
+ importFromYaml(content);
53
+ return { content, obj: jsyaml.load(content) };
54
+ };
55
+
56
+ // ---------------------------------------------------------------------------------------------
57
+ // project init
58
+
59
+ // Starts a project with a first map, or with a map from each file, written to storymaps/ by ID.
60
+ // The original files are left alone. Needs a connection, as the server makes projects.
61
+ export async function projectInitCommand(files, opts) {
62
+ const dir = await defaultProjectDir(opts.dir);
63
+ const existing = await readProjectFiles(dir);
64
+ if (existing.length) {
65
+ throw new StopError(`${shortPath(dir)} already holds a project, ${existing[0].project}. Use --dir for the new one.`);
66
+ }
67
+ const baseUrl = siteToBaseUrl(opts.site);
68
+ const sources = [];
69
+ for (const file of files) {
70
+ try {
71
+ sources.push({ file, ...(await readMapFile(file)) });
72
+ } catch (err) {
73
+ throw new StopError(`${file} isn't a valid map:\n${err.message}\nNothing was created.`);
74
+ }
75
+ }
76
+ if (!sources.length) sources.push({ content: template(opts.site), obj: { name: 'My Story Map' } });
77
+
78
+ // A project needs at least one map, so the maps come first
79
+ const ids = [];
80
+ for (const source of sources) ids.push(await createMap(baseUrl, source.content.replace(/^id:[^\n]*\n?/m, '').replace(/^project:[^\n]*\n?/m, '')));
81
+ const project = await createProject(baseUrl, ids, opts.name);
82
+
83
+ console.log(chalk.green(`Created project ${project.name}: ${projectLink(baseUrl, project.id)}`));
84
+ const width = Math.max(...project.maps.map(m => m.name.length));
85
+ for (const map of project.maps) console.log(` ${map.name.padEnd(width)} ${projectLink(baseUrl, project.id, map.id)}`);
86
+ const written = [];
87
+ for (const id of ids) written.push(shortPath(await writeFromServer(baseUrl, project.id, id, dir)));
88
+ console.log(`Wrote ${listNames(written)}`);
89
+ if (files.length) console.log(chalk.dim(`${listNames(files)} ${files.length === 1 ? 'is' : 'are'} unchanged. Delete ${files.length === 1 ? 'it' : 'them'} when you're ready.`));
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------------------------
93
+ // project add
94
+
95
+ // The project to add to: the project folder, or --project when there isn't one yet
96
+ async function projectToChange(opts) {
97
+ if (opts.project) {
98
+ const link = parseProjectLink(opts.project);
99
+ const projectId = link ? link.projectId : opts.project;
100
+ const baseUrl = link ? link.baseUrl : siteToBaseUrl();
101
+ return { projectId, baseUrl, dir: await defaultProjectDir(opts.dir) };
102
+ }
103
+ const ctx = await findProjectFolder({ dir: opts.dir, quiet: true });
104
+ if (!ctx?.projectId) {
105
+ throw new StopError('There\'s no project here. Run this in a project folder, or say which project with --project storymaps.io/p/<project>.');
106
+ }
107
+ return ctx;
108
+ }
109
+
110
+ const isOffline = (err) => err instanceof TypeError || err?.cause?.code === 'ENOTFOUND' || err?.cause?.code === 'ECONNREFUSED' || err?.code === 'ENOTFOUND';
111
+
112
+ // Adds a new blank map, a new map from a file that isn't on storymaps.io, or an existing map,
113
+ // by its file, link or ID. Online, the map is created and added straight away. Offline, the CLI
114
+ // makes the map ID and the next push creates the map and adds it.
115
+ export async function projectAddCommand(source, opts) {
116
+ const { projectId, baseUrl, dir } = await projectToChange(opts);
117
+
118
+ // What's being added
119
+ let existingId = null, content = null, name = opts.name || 'My Story Map', draft = null;
120
+ if (source && /\.ya?ml$/i.test(source)) {
121
+ let read;
122
+ try {
123
+ read = await readMapFile(source);
124
+ } catch (err) {
125
+ throw new StopError(`${source} isn't a valid map:\n${err.message}`);
126
+ }
127
+ name = read.obj.name || name;
128
+ const id = read.obj.id ? String(read.obj.id) : null;
129
+ const exists = id ? await mapExists(baseUrl, id).catch(err => { if (isOffline(err)) return null; throw err; }) : false;
130
+ if (exists === true) existingId = id;
131
+ else {
132
+ content = read.content;
133
+ draft = resolve(source);
134
+ }
135
+ } else if (source) {
136
+ existingId = (await resolveTarget(source)).id;
137
+ } else {
138
+ content = template().replace(/^name: My Story Map$/m, jsyaml.dump({ name }).trim());
139
+ }
140
+
141
+ let project;
142
+ try {
143
+ project = await getProject(baseUrl, projectId);
144
+ } catch (err) {
145
+ if (existingId || !isOffline(err)) throw err;
146
+ return addOffline({ baseUrl, projectId, dir, content, name, draft });
147
+ }
148
+ if (existingId) name = (await getMap(baseUrl, existingId, 'json')).data.name || 'this map';
149
+ const hash = await projectPassword(baseUrl, project, `${project.name} is locked. Enter its password to add ${name}`);
150
+
151
+ let id = existingId;
152
+ if (!id) {
153
+ const body = content.replace(/^id:[^\n]*\n?/m, '').replace(/^project:[^\n]*\n?/m, '');
154
+ id = await createMap(baseUrl, body);
155
+ console.log(chalk.green(`Created ${name}: ${projectLink(baseUrl, projectId, id)}`));
156
+ }
157
+ const after = await addProjectMap(baseUrl, projectId, id, hash || undefined);
158
+ const map = after.maps.find(m => m.id === id);
159
+ console.log(chalk.green(`Added ${map?.name || name} to ${project.name}.`));
160
+ reportLock(map?.name || name, map);
161
+ const path = await writeFromServer(baseUrl, projectId, id, dir);
162
+ console.log(`Wrote ${shortPath(path)}`);
163
+ if (draft) {
164
+ await noteDraft(draft, path);
165
+ console.log(chalk.dim(`${shortPath(draft)} is unchanged. Delete it when you're ready.`));
166
+ } else if (source && /\.ya?ml$/i.test(source)) {
167
+ console.log(chalk.dim(`${shortPath(resolve(source))} is unchanged. Delete it when you're ready.`));
168
+ }
169
+ }
170
+
171
+ // With no connection: the CLI makes the ID and writes the file; the next push creates the map
172
+ async function addOffline({ baseUrl, projectId, dir, content, name, draft }) {
173
+ const id = generateId();
174
+ const path = mapFilePath(dir, id);
175
+ await writeMapFile(path, setSiteKey(setProjectKey(setIdKey(content, id), projectId), siteOfBaseUrl(baseUrl)));
176
+ await noteNewMap(path, id);
177
+ if (draft) await noteDraft(draft, path);
178
+ console.log(`Wrote ${shortPath(path)}`);
179
+ console.log(chalk.yellow(`You're offline, so ${name} will be created and added to the project on your next push.`));
180
+ if (draft) console.log(chalk.dim(`${shortPath(draft)} is unchanged. Delete it when you're ready.`));
181
+ return { baseUrl };
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------------------------
185
+ // project lock
186
+
187
+ // The same two choices as the app: the project, or the project and its maps that aren't locked
188
+ export async function projectLockCommand(opts) {
189
+ const { projectId, baseUrl, dir } = await projectToChange(opts);
190
+ const project = await getProject(baseUrl, projectId);
191
+ const unlocked = project.maps.filter(m => !m.lock);
192
+ const keeping = project.maps.filter(m => m.lock === 'map').map(m => m.name);
193
+ const keepNote = keeping.length ? `${listNames(keeping)} ${keeping.length === 1 ? 'keeps its own lock.' : 'keep their own locks.'}` : '';
194
+
195
+ if (project.locked) {
196
+ if (!opts.maps) {
197
+ console.log(`${project.name} is already locked.` + (unlocked.length ? chalk.dim(` To lock its ${plural(unlocked.length, 'unlocked map')} too, run storymaps project lock --maps`) : ''));
198
+ return;
199
+ }
200
+ if (!unlocked.length) {
201
+ console.log(chalk.dim(`${project.name} and its maps are already locked.`));
202
+ return;
203
+ }
204
+ if (!process.env[PASSWORD_ENV]) console.log(chalk.yellow(`${project.name} is already locked. Enter its password to lock its unlocked maps.`));
205
+ const hash = await askPassword({ noPasswordMessage: `${project.name} is locked, and there's no terminal to type its password in. Set ${PASSWORD_ENV} for this command.` });
206
+ const result = await lockProject(baseUrl, projectId, hash, true);
207
+ await updateLockedInFolder(dir, project, result.project);
208
+ const nowLocked = result.project.maps.filter(m => m.lock === 'project' && unlocked.some(u => u.id === m.id)).map(m => m.name);
209
+ console.log(chalk.green(`Locked ${listNames(nowLocked)} with the project password.`) + chalk.dim(' Every other map was already locked.'));
210
+ return;
211
+ }
212
+
213
+ const withMaps = opts.maps || await choose({
214
+ message: 'What should the password lock?',
215
+ yes: opts.yes,
216
+ defaultValue: false,
217
+ choices: [
218
+ { name: 'The project', value: false, description: 'Nobody can rename the project, or add, remove or reorder its maps, without the password. What\'s on each map can still be edited.' },
219
+ ...(unlocked.length ? [{
220
+ name: `The project and its ${plural(unlocked.length, keeping.length ? 'unlocked map' : 'map')}`,
221
+ value: true,
222
+ description: `Nobody can edit ${unlocked.length === 1 ? 'that map' : 'those maps'} without the password either, and ${unlocked.length === 1 ? 'it' : 'each one'} can be unlocked on its own later. ${keepNote}`.trim(),
223
+ }] : []),
224
+ ],
225
+ });
226
+ const hash = await askPassword({ confirmTwice: true, noPasswordMessage: `Locking needs a password, and there's no terminal to type it in. Set ${PASSWORD_ENV} for this command.` });
227
+ const result = await lockProject(baseUrl, projectId, hash, withMaps);
228
+ await updateLockedInFolder(dir, project, result.project);
229
+ await rememberProjectPassword(projectId, hash);
230
+ // Said from the labels after locking: a map locked before with this same password now counts
231
+ // as locked with the project password, not with its own
232
+ const lockedMaps = withMaps ? result.project.maps.filter(m => m.lock === 'project' && unlocked.some(u => u.id === m.id)).length : 0;
233
+ const ownAfter = result.project.maps.filter(m => m.lock === 'map').map(m => m.name);
234
+ const ownNote = ownAfter.length ? `${listNames(ownAfter)} ${ownAfter.length === 1 ? 'keeps its own lock.' : 'keep their own locks.'}` : '';
235
+ console.log(chalk.green(`Locked ${project.name}${lockedMaps ? ` and its ${plural(lockedMaps, keeping.length ? 'unlocked map' : 'map')}` : ''}.`) + (ownNote ? chalk.dim(` ${ownNote}`) : ''));
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------------------------
239
+ // project unlock
240
+
241
+ // On this computer, as entering the password in the app does, or for everyone with --remove.
242
+ // Both ask about the maps that use the project password, defaulting to no as the app does.
243
+ export async function projectUnlockCommand(opts) {
244
+ const { projectId, baseUrl, dir } = await projectToChange(opts);
245
+ const project = await getProject(baseUrl, projectId);
246
+ if (!project.locked) {
247
+ console.log(chalk.dim(`${project.name} isn't locked.`));
248
+ return;
249
+ }
250
+ const shared = project.maps.filter(m => m.lock === 'project');
251
+ const own = project.maps.filter(m => m.lock === 'map');
252
+
253
+ if (opts.remove) {
254
+ console.log(chalk.yellow(`Anyone with the link will be able to change the name, maps and order of ${project.name}, and delete it.`));
255
+ const hash = await askPassword({ noPasswordMessage: `Removing the lock needs the ${project.name} password, and there's no terminal to type it in. Set ${PASSWORD_ENV} for this command.` });
256
+ const removeMaps = shared.length ? (opts.maps || await ask({ message: `Also remove the lock from ${plural(shared.length, 'map')}? ${shared.length === 1 ? 'It has' : 'They have'} a project lock.`, yes: opts.yes })) : false;
257
+ try {
258
+ const after = await removeProjectLock(baseUrl, projectId, hash, removeMaps);
259
+ await updateLockedInFolder(dir, project, after);
260
+ } catch (err) {
261
+ if (err instanceof ApiError && err.status === 403) throw new StopError('Incorrect password. Nothing was changed.');
262
+ throw err;
263
+ }
264
+ await forgetProjectPassword(projectId);
265
+ if (removeMaps) for (const map of shared) await writeCache(map.id, { hash: null });
266
+ console.log(removeMaps
267
+ ? chalk.green(`Removed the lock from ${project.name} and ${plural(shared.length, 'map')}.`)
268
+ : chalk.green(`Removed the lock from ${project.name}.`) + (shared.length ? chalk.dim(' Its maps keep their locks.') : ''));
269
+ return;
270
+ }
271
+
272
+ const hash = await askPassword({ noPasswordMessage: `Unlocking needs the ${project.name} password, and there's no terminal to type it in. Set ${PASSWORD_ENV} for this command.` });
273
+ try {
274
+ await checkProjectPassword(baseUrl, projectId, hash);
275
+ } catch (err) {
276
+ if (err instanceof ApiError && err.status === 403) throw new StopError('Incorrect password.');
277
+ throw err;
278
+ }
279
+ await rememberProjectPassword(projectId, hash);
280
+ const withMaps = shared.length ? (opts.maps || await ask({ message: `Also unlock the ${plural(shared.length, 'map')} locked with the project password, on this computer?`, yes: opts.yes })) : false;
281
+ if (withMaps) {
282
+ for (const map of shared) await writeCache(map.id, { hash });
283
+ console.log(chalk.green(`Unlocked ${project.name} and ${plural(shared.length, 'map')} on this computer.`));
284
+ } else {
285
+ console.log(chalk.green(`Unlocked ${project.name} on this computer.`) + (shared.length ? chalk.dim(' Its maps stay locked. Add --maps to unlock them too.') : ''));
286
+ }
287
+ for (const map of own) {
288
+ const file = shortPath(mapFilePath(dir, map.id));
289
+ console.log(chalk.dim(`${map.name} is still locked. To unlock it, run`) + ` ${chalk.cyan(`storymaps unlock -f ${file}`)}`);
290
+ }
291
+ }
@@ -1,17 +1,19 @@
1
1
  // Storymaps CLI — AGPL-3.0 — see LICENCE for details
2
- // storymaps pull — download a map from the remote server
2
+ // storymaps pull — download a map, or a whole project, from the remote server
3
3
 
4
4
  import { readFile, writeFile } from 'node:fs/promises';
5
5
  import { existsSync } from 'node:fs';
6
6
  import { resolve } from 'node:path';
7
- import { confirm } from '@inquirer/prompts';
8
7
  import chalk from 'chalk';
9
8
  import jsyaml from 'js-yaml';
10
9
  import { exportToYaml, yamlObjToJson } from '../core/yaml.js';
11
10
  import { detectFormat, writeEtag } from '../util/file.js';
12
- import { resolveTarget } from '../util/identity.js';
13
- import { getMap } from '../util/api.js';
11
+ import { resolveTarget, parseProjectLink } from '../util/identity.js';
12
+ import { getMap, getProject } from '../util/api.js';
14
13
  import { printChangeSummary } from '../util/summary.js';
14
+ import { findProjectFolder, readProjectFiles, defaultProjectDir, shortPath } from '../util/project.js';
15
+ import { choose, guard, isInteractive } from '../util/prompt.js';
16
+ import { pullProject, projectLink } from './project-sync.js';
15
17
 
16
18
  function normalise(content, format) {
17
19
  if (format === 'json') {
@@ -22,6 +24,52 @@ function normalise(content, format) {
22
24
  }
23
25
 
24
26
  export async function pullCommand(target, opts) {
27
+ // A project link: the whole project, or one map of it
28
+ const link = parseProjectLink(target);
29
+ if (link && !opts.file) {
30
+ const dir = await defaultProjectDir(opts.dir);
31
+ if (!link.mapId) {
32
+ await pullProject({ baseUrl: link.baseUrl, projectId: link.projectId, dir, force: opts.force });
33
+ return;
34
+ }
35
+ // The app's address bar always shows a map inside its project, so a map link is what most
36
+ // people paste. The first time, ask which they meant; once the folder holds the project,
37
+ // a map link pulls just that map.
38
+ const held = (await readProjectFiles(dir)).some(f => f.project === link.projectId);
39
+ if (!held) {
40
+ const project = await getProject(link.baseUrl, link.projectId);
41
+ const map = project.maps.find(m => m.id === link.mapId);
42
+ const name = map?.name || link.mapId;
43
+ const asking = isInteractive() && !opts.yes;
44
+ if (asking) console.log(`${name} is one of ${project.maps.length} maps in ${project.name}.`);
45
+ const whole = await choose({
46
+ message: 'Pull:',
47
+ yes: opts.yes,
48
+ defaultValue: false,
49
+ choices: [
50
+ { name: `Just ${name}`, value: false },
51
+ { name: `The whole project (${project.maps.length} maps)`, value: true },
52
+ ],
53
+ });
54
+ await pullProject({ baseUrl: link.baseUrl, projectId: link.projectId, dir, force: opts.force, ...(whole ? {} : { mapId: link.mapId }) });
55
+ if (!whole && !asking && project.maps.length > 1) {
56
+ console.log(chalk.dim(`${name} is one of ${project.maps.length} maps in ${project.name}. To pull them all, run`) + ` ${chalk.cyan(`storymaps pull ${projectLink(link.baseUrl, link.projectId).replace(/^https:\/\//, '')}`)}`);
57
+ }
58
+ return;
59
+ }
60
+ await pullProject({ baseUrl: link.baseUrl, projectId: link.projectId, dir, force: opts.force, mapId: link.mapId });
61
+ return;
62
+ }
63
+
64
+ // No target in a project folder: refresh every map
65
+ if (!target) {
66
+ const ctx = await findProjectFolder(opts);
67
+ if (ctx?.projectId) {
68
+ await pullProject({ baseUrl: ctx.baseUrl, projectId: ctx.projectId, dir: ctx.dir, force: opts.force });
69
+ return;
70
+ }
71
+ }
72
+
25
73
  const filePath = resolve(opts.file || 'storymap.yml');
26
74
  const { id, baseUrl } = await resolveTarget(target, filePath);
27
75
 
@@ -50,16 +98,10 @@ export async function pullCommand(target, opts) {
50
98
  // Continue even if summary fails
51
99
  }
52
100
 
53
- if (!opts.force) {
54
- const overwrite = await confirm({
55
- message: 'Apply changes?',
56
- default: true,
57
- theme: { prefix: '>' },
58
- });
59
- if (!overwrite) {
60
- console.log('Aborted.');
61
- return;
62
- }
101
+ const overwrite = await guard({ message: 'Apply changes?', force: opts.force, stopMessage: `${shortPath(filePath)} was left as it is.` });
102
+ if (!overwrite) {
103
+ console.log('Aborted.');
104
+ return;
63
105
  }
64
106
  }
65
107
 
@@ -1,17 +1,18 @@
1
1
  // Storymaps CLI — AGPL-3.0 — see LICENCE for details
2
2
  // storymaps push — upload a local map to the remote server
3
3
 
4
- import { createHash } from 'node:crypto';
5
4
  import { readFile, writeFile } from 'node:fs/promises';
6
5
  import { resolve } from 'node:path';
7
6
  import chalk from 'chalk';
8
- import { password } from '@inquirer/prompts';
9
7
  import jsyaml from 'js-yaml';
10
8
  import { exportToYaml, importFromYaml, yamlObjToJson } from '../core/yaml.js';
11
9
  import { printChangeSummary } from '../util/summary.js';
12
- import { resolveTarget } from '../util/identity.js';
10
+ import { resolveTarget, siteToBaseUrl } from '../util/identity.js';
13
11
  import { getMap, putMap, postMap, ApiError } from '../util/api.js';
14
12
  import { detectFormat, readCache, readEtag, writeEtag } from '../util/file.js';
13
+ import { findProjectFolder } from '../util/project.js';
14
+ import { askPassword, isInteractive, PASSWORD_ENV } from '../util/prompt.js';
15
+ import { pushProject } from './project-sync.js';
15
16
 
16
17
  // The map as it is on the server now is what this file matches, so a later push from it stops if
17
18
  // the map changes in the meantime
@@ -22,13 +23,17 @@ async function rememberEtag(baseUrl, id, filePath) {
22
23
  } catch { /* non-fatal */ }
23
24
  }
24
25
 
25
- function siteToBaseUrl(site) {
26
- if (!site) return 'https://storymaps.io';
27
- if (site === 'localhost') return 'http://localhost:3000';
28
- return `https://${site}`;
29
- }
30
26
 
31
27
  export async function pushCommand(target, opts) {
28
+ // No target or -f in a project folder: every map in the project
29
+ if (!target) {
30
+ const ctx = await findProjectFolder(opts);
31
+ if (ctx?.projectId) {
32
+ await pushProject(ctx, { force: opts.force });
33
+ return;
34
+ }
35
+ }
36
+
32
37
  const filePath = resolve(opts.file || 'storymap.yml');
33
38
 
34
39
  let content;
@@ -101,9 +106,8 @@ export async function pushCommand(target, opts) {
101
106
  process.exit(1);
102
107
  }
103
108
  if (err instanceof ApiError && err.status === 423) {
104
- console.log(chalk.yellow('Map is locked.'));
105
- const pw = await password({ theme: { prefix: '>' }, message: 'Password:' });
106
- lockPasswordHash = createHash('sha256').update(pw).digest('hex');
109
+ if (!process.env[PASSWORD_ENV] && isInteractive()) console.log(chalk.yellow('Map is locked.'));
110
+ lockPasswordHash = await askPassword({ noPasswordMessage: `This map is locked, and there's no terminal to type its password in. Nothing was pushed. Run storymaps unlock in a terminal first, or set ${PASSWORD_ENV} for this command.` });
107
111
  try {
108
112
  await putMap(baseUrl, id, content, { etag, contentType, lockPasswordHash });
109
113
  } catch (retryErr) {
@@ -121,6 +125,8 @@ export async function pushCommand(target, opts) {
121
125
  // Refresh this file's ETag so the next push from it is also protected
122
126
  await rememberEtag(baseUrl, id, filePath);
123
127
 
124
- const url = `${baseUrl}/${id}`;
128
+ // A map in a project shows at its project link
129
+ const project = format === 'yaml' ? jsyaml.load(content)?.project : JSON.parse(content).project;
130
+ const url = project ? `${baseUrl}/p/${project}/${id}` : `${baseUrl}/${id}`;
125
131
  console.log(chalk.green(`Pushed to ${url}`));
126
132
  }
@@ -5,7 +5,8 @@ import { readFile } from 'node:fs/promises';
5
5
  import { resolve } from 'node:path';
6
6
  import chalk from 'chalk';
7
7
  import jsyaml from 'js-yaml';
8
- import { resolveTarget } from '../util/identity.js';
8
+ import { resolveTarget, parseProjectLink } from '../util/identity.js';
9
+ import { findProjectFolder } from '../util/project.js';
9
10
  import { getMap, ApiError } from '../util/api.js';
10
11
 
11
12
  const STATUS_ICONS = {
@@ -52,7 +53,43 @@ async function loadContent(target, opts) {
52
53
  }
53
54
  }
54
55
 
56
+ // Task cards across every slice, and how many are done. Cards in a legend colour not labelled as
57
+ // a task, such as notes and questions, aren't counted.
58
+ export function storyCounts(map) {
59
+ const nonTaskColors = new Set((map.legend || []).filter(e => !/task/i.test(e.label || '')).map(e => e.color));
60
+ let done = 0, total = 0;
61
+ for (const slice of map.slices || []) {
62
+ for (const cards of Object.values(slice.stories || {})) {
63
+ if (!Array.isArray(cards)) continue;
64
+ for (const card of cards) {
65
+ if (card.color && nonTaskColors.has(card.color)) continue;
66
+ total++;
67
+ if (card.status === 'done') done++;
68
+ }
69
+ }
70
+ }
71
+ return { done, total };
72
+ }
73
+
74
+ export { bar, pct };
75
+
55
76
  export async function statusCommand(target, opts) {
77
+ // A project: one line per map and a total, from the folder or, for a project link, the server
78
+ const link = parseProjectLink(target);
79
+ if (link && !link.mapId) {
80
+ const { statusProject } = await import('./project-sync.js');
81
+ await statusProject({ baseUrl: link.baseUrl, projectId: link.projectId, files: null });
82
+ return;
83
+ }
84
+ if (!target) {
85
+ const ctx = await findProjectFolder(opts);
86
+ if (ctx?.projectId) {
87
+ const { statusProject } = await import('./project-sync.js');
88
+ await statusProject(ctx);
89
+ return;
90
+ }
91
+ }
92
+
56
93
  const content = await loadContent(target, opts);
57
94
 
58
95
  const map = jsyaml.load(content);
@@ -1,35 +1,50 @@
1
1
  // Storymaps CLI — AGPL-3.0 — see LICENCE for details
2
- // storymaps unlock — session unlock or permanently remove a map lock
2
+ // storymaps unlock — unlock one map on this computer, or remove its lock.
3
+ // Unlocking a whole project is storymaps project unlock.
3
4
 
4
- import { createHash } from 'node:crypto';
5
+ import { readFile } from 'node:fs/promises';
5
6
  import { resolve } from 'node:path';
6
- import { password } from '@inquirer/prompts';
7
7
  import chalk from 'chalk';
8
+ import jsyaml from 'js-yaml';
8
9
  import { resolveTarget } from '../util/identity.js';
9
- import { unlockMap, removeLock } from '../util/api.js';
10
+ import { unlockMap, removeLock, ApiError } from '../util/api.js';
10
11
  import { writeCache } from '../util/file.js';
12
+ import { findProjectFolder, setLockedInFile } from '../util/project.js';
13
+ import { askPassword, StopError, PASSWORD_ENV } from '../util/prompt.js';
11
14
 
12
15
  export async function unlockCommand(target, opts) {
16
+ if (!target && !opts.file && await findProjectFolder(opts)) {
17
+ throw new StopError('Name a map with -f. To unlock the whole project, run storymaps project unlock');
18
+ }
13
19
  const filePath = resolve(opts.file || 'storymap.yml');
14
20
  let id, baseUrl;
15
21
  try {
16
22
  ({ id, baseUrl } = await resolveTarget(target, filePath));
17
- } catch {
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
- process.exitCode = 1;
20
- return;
23
+ } catch (err) {
24
+ if (/whole project/.test(err.message)) throw new StopError(`${err.message.split('.')[0]}. To unlock the whole project, run storymaps project unlock`);
25
+ throw new StopError('No map ID found. Run "storymaps push" to create it on the server, or pass one directly: storymaps unlock <id>');
26
+ }
27
+ let name = id;
28
+ if (!target) {
29
+ try {
30
+ name = jsyaml.load(await readFile(filePath, 'utf-8'))?.name || id;
31
+ } catch { /* no file */ }
21
32
  }
22
33
 
23
- const pw = await password({ theme: { prefix: '>' }, message: 'Password:' });
24
- const passwordHash = createHash('sha256').update(pw).digest('hex');
25
-
26
- if (opts.remove) {
27
- await removeLock(baseUrl, id, passwordHash);
28
- await writeCache(id, { hash: null });
29
- console.log(chalk.green(`Lock removed from map ${id}.`));
30
- } else {
31
- await unlockMap(baseUrl, id, passwordHash);
32
- await writeCache(id, { hash: passwordHash });
33
- console.log(chalk.green(`Map ${id} unlocked.`));
34
+ const passwordHash = await askPassword({ noPasswordMessage: `Unlocking needs the password, and there's no terminal to type it in. Set ${PASSWORD_ENV} for this command.` });
35
+ try {
36
+ if (opts.remove) {
37
+ await removeLock(baseUrl, id, passwordHash);
38
+ if (!target) await setLockedInFile(filePath, false);
39
+ await writeCache(id, { hash: null });
40
+ console.log(chalk.green(`Removed the lock from ${name}.`));
41
+ } else {
42
+ await unlockMap(baseUrl, id, passwordHash);
43
+ await writeCache(id, { hash: passwordHash });
44
+ console.log(chalk.green(`Unlocked ${name} on this computer.`));
45
+ }
46
+ } catch (err) {
47
+ if (err instanceof ApiError && err.status === 403) throw new StopError('Incorrect password.');
48
+ throw err;
34
49
  }
35
50
  }
@@ -1,11 +1,40 @@
1
1
  // Storymaps CLI — AGPL-3.0 — see LICENCE for details
2
2
  // storymaps validate — validate a storymap file
3
3
 
4
+ import { existsSync, statSync } from 'node:fs';
5
+ import { readdir } from 'node:fs/promises';
6
+ import { join } from 'node:path';
4
7
  import chalk from 'chalk';
5
8
  import { readStorymap } from '../util/file.js';
6
9
  import { validateYaml } from '../core/yaml.js';
7
10
 
8
11
  export async function validateCommand(file) {
12
+ // A folder, such as storymaps: every map file in it
13
+ if (existsSync(file) && statSync(file).isDirectory()) {
14
+ const names = (await readdir(file)).filter(n => /\.ya?ml$/i.test(n)).sort();
15
+ let bad = 0;
16
+ for (const name of names) {
17
+ const path = join(file, name);
18
+ let result;
19
+ try {
20
+ result = validateYaml(await readStorymap(path));
21
+ } catch (err) {
22
+ result = { valid: false, errors: [err.message], warnings: [] };
23
+ }
24
+ if (result.valid) {
25
+ console.log(` ${name} ${chalk.green('valid')}${result.warnings.length ? chalk.yellow(`, ${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'}`) : ''}`);
26
+ } else {
27
+ bad++;
28
+ console.log(` ${name} ${chalk.red('not valid')}`);
29
+ for (const err of result.errors) console.error(chalk.red(` - ${err}`));
30
+ }
31
+ for (const warn of result.warnings) console.warn(chalk.yellow(` - ${warn}`));
32
+ }
33
+ if (!names.length) console.log(chalk.dim(`No map files in ${file}.`));
34
+ else if (!bad) console.log(chalk.green(`All ${names.length} maps are valid.`));
35
+ process.exit(bad ? 1 : 0);
36
+ }
37
+
9
38
  let data;
10
39
  try {
11
40
  data = await readStorymap(file);
package/lib/core/yaml.js CHANGED
@@ -88,6 +88,9 @@ export function jsonToYamlObj(data) {
88
88
  const result = {};
89
89
  result.name = data.name || '';
90
90
  if (data.id) result.id = data.id;
91
+ // The project a map was fetched through, if any: a description of the map, like id and site,
92
+ // so loading the YAML back into a map ignores it
93
+ if (data.project) result.project = data.project;
91
94
  if (data.site) result.site = data.site;
92
95
  if (data.locked != null) result.locked = data.locked;
93
96
 
@@ -235,6 +238,11 @@ export function validateYaml(obj) {
235
238
  return { valid: false, errors, warnings };
236
239
  }
237
240
 
241
+ // Same characters the server allows in a project ID
242
+ if (obj.project != null && !(typeof obj.project === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(obj.project))) {
243
+ errors.push('"project" must be a project ID, such as the part after /p/ in a project link');
244
+ }
245
+
238
246
  const stepNames = [];
239
247
  obj.steps.forEach((step, i) => {
240
248
  if (step === 'spacer') return;