storymaps 1.3.5 → 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.
- package/README.md +105 -1
- package/bin/storymaps.js +88 -25
- package/lib/commands/backup.js +37 -35
- package/lib/commands/diff.js +58 -38
- package/lib/commands/init.js +1 -1
- package/lib/commands/lock.js +42 -20
- package/lib/commands/open.js +21 -3
- package/lib/commands/project-sync.js +509 -0
- package/lib/commands/project.js +291 -0
- package/lib/commands/pull.js +56 -14
- package/lib/commands/push.js +18 -12
- package/lib/commands/status.js +38 -1
- package/lib/commands/unlock.js +34 -19
- package/lib/commands/validate.js +29 -0
- package/lib/core/yaml.js +8 -0
- package/lib/util/api.js +76 -4
- package/lib/util/full-diff.js +42 -0
- package/lib/util/identity.js +34 -13
- package/lib/util/passwords.js +101 -0
- package/lib/util/project.js +215 -0
- package/lib/util/prompt.js +73 -0
- package/lib/util/summary.js +4 -3
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/lib/commands/pull.js
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
package/lib/commands/push.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/commands/status.js
CHANGED
|
@@ -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);
|
package/lib/commands/unlock.js
CHANGED
|
@@ -1,35 +1,50 @@
|
|
|
1
1
|
// Storymaps CLI — AGPL-3.0 — see LICENCE for details
|
|
2
|
-
// storymaps unlock —
|
|
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 {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
}
|
package/lib/commands/validate.js
CHANGED
|
@@ -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;
|