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.
- package/README.md +105 -1
- package/bin/storymaps.js +89 -26
- package/lib/commands/backup.js +37 -35
- package/lib/commands/diff.js +58 -38
- package/lib/commands/init.js +12 -3
- 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 +59 -19
- package/lib/commands/push.js +34 -24
- package/lib/commands/status.js +40 -3
- package/lib/commands/unlock.js +34 -19
- package/lib/commands/validate.js +29 -0
- package/lib/core/yaml.js +23 -0
- package/lib/util/api.js +78 -6
- package/lib/util/file.js +15 -0
- 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
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
|
|
|
@@ -198,9 +201,24 @@ export function jsonToYamlObj(data) {
|
|
|
198
201
|
// Import: YAML string → JSON (compatible with deserializeV1)
|
|
199
202
|
// ============================================================================
|
|
200
203
|
|
|
204
|
+
// YAML aliases let a few hundred bytes stand for millions of values (an "alias bomb"), which
|
|
205
|
+
// would run the server out of memory once read. Count the values, an alias each time it is
|
|
206
|
+
// used, and stop well above the size of any real story map.
|
|
207
|
+
const MAX_YAML_VALUES = 1_000_000;
|
|
208
|
+
const checkYamlSize = (root) => {
|
|
209
|
+
let count = 0;
|
|
210
|
+
const stack = [root];
|
|
211
|
+
while (stack.length) {
|
|
212
|
+
const value = stack.pop();
|
|
213
|
+
if (++count > MAX_YAML_VALUES) throw new Error('YAML is too large');
|
|
214
|
+
if (value && typeof value === 'object') for (const child of Object.values(value)) stack.push(child);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
201
218
|
export function importFromYaml(yamlString) {
|
|
202
219
|
const obj = jsyaml.load(yamlString);
|
|
203
220
|
if (!obj || typeof obj !== 'object') throw new Error('Invalid YAML');
|
|
221
|
+
checkYamlSize(obj);
|
|
204
222
|
const validation = validateYaml(obj);
|
|
205
223
|
if (!validation.valid) {
|
|
206
224
|
const err = new Error(validation.errors.join('\n'));
|
|
@@ -220,6 +238,11 @@ export function validateYaml(obj) {
|
|
|
220
238
|
return { valid: false, errors, warnings };
|
|
221
239
|
}
|
|
222
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
|
+
|
|
223
246
|
const stepNames = [];
|
|
224
247
|
obj.steps.forEach((step, i) => {
|
|
225
248
|
if (step === 'spacer') return;
|
package/lib/util/api.js
CHANGED
|
@@ -32,24 +32,31 @@ 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
|
|
36
|
-
case
|
|
37
|
-
case
|
|
35
|
+
case 409: throw new ApiError(body || 'Conflict: the remote map has changed. Pull first or use --force.', 409, { conflict: true });
|
|
36
|
+
case 410: throw new ApiError('This map was deleted', 410);
|
|
37
|
+
case 412: throw new ApiError(body || 'A map already has this ID', 412);
|
|
38
|
+
case 423: throw new ApiError(body && body !== 'Map is locked' ? body : 'Map is locked', 423);
|
|
39
|
+
case 429: throw new ApiError('Rate limited. Please wait and try again.', 429);
|
|
38
40
|
default: throw new ApiError(body || `Request failed (${res.status})`, res.status);
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
// With a project, the map comes with a project key, and only if it's in that project
|
|
45
|
+
export async function getMap(baseUrl, id, format = 'yaml', { project } = {}) {
|
|
46
|
+
const query = project ? `&project=${encodeURIComponent(project)}` : '';
|
|
47
|
+
const res = await request(`${baseUrl}/api/maps/${id}?format=${format}${query}`);
|
|
48
|
+
if (project && res.status === 404) throw new ApiError('That map is not in this project', 404);
|
|
44
49
|
await handleResponse(res);
|
|
45
50
|
const etag = res.headers.get('etag');
|
|
46
51
|
const data = format === 'json' ? await res.json() : await res.text();
|
|
47
52
|
return { data, etag };
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
|
|
55
|
+
// createOnly makes the map only if no map has the ID, answering 412 otherwise
|
|
56
|
+
export async function putMap(baseUrl, id, body, { etag, contentType = 'text/yaml', lockPasswordHash, restoredBackup, createOnly } = {}) {
|
|
51
57
|
const headers = { 'Content-Type': contentType };
|
|
52
58
|
if (etag) headers['If-Match'] = etag;
|
|
59
|
+
if (createOnly) headers['If-None-Match'] = '*';
|
|
53
60
|
if (lockPasswordHash) headers['X-Lock-Password'] = lockPasswordHash;
|
|
54
61
|
// Tells the server this push is a restore, so the activity log says so
|
|
55
62
|
if (restoredBackup) headers['X-Restored-Backup'] = restoredBackup;
|
|
@@ -150,4 +157,69 @@ export async function createBackup(baseUrl, id, note = '', { lockPasswordHash }
|
|
|
150
157
|
return res.json();
|
|
151
158
|
}
|
|
152
159
|
|
|
160
|
+
// Whether a map is on the server: true, false, or 'deleted'
|
|
161
|
+
export async function mapExists(baseUrl, id) {
|
|
162
|
+
const res = await request(`${baseUrl}/api/maps/${id}/meta`);
|
|
163
|
+
if (res.status === 410) return 'deleted';
|
|
164
|
+
if (res.status === 404) return false;
|
|
165
|
+
await handleResponse(res);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Projects
|
|
170
|
+
|
|
171
|
+
const postJson = async (url, body) => {
|
|
172
|
+
const res = await request(url, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: { 'Content-Type': 'application/json' },
|
|
175
|
+
body: JSON.stringify(body),
|
|
176
|
+
});
|
|
177
|
+
if (res.status === 404) {
|
|
178
|
+
const message = errorMessage(await res.text().catch(() => ''));
|
|
179
|
+
throw new ApiError(message || 'Project not found', 404);
|
|
180
|
+
}
|
|
181
|
+
if (res.status === 403) {
|
|
182
|
+
const message = errorMessage(await res.text().catch(() => ''));
|
|
183
|
+
throw new ApiError(message === 'Forbidden'
|
|
184
|
+
? 'This server does not take project changes from the CLI yet. Make the change in the app, or update the server'
|
|
185
|
+
: message || 'Incorrect password', 403);
|
|
186
|
+
}
|
|
187
|
+
if (res.status === 400 || res.status === 409 || res.status === 423) {
|
|
188
|
+
const message = errorMessage(await res.text().catch(() => ''));
|
|
189
|
+
throw new ApiError(message, res.status);
|
|
190
|
+
}
|
|
191
|
+
await handleResponse(res);
|
|
192
|
+
return res.json();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// The project's name, whether it's locked, and its maps in tab order. A locked map says whether it
|
|
196
|
+
// uses the project password ('project') or its own ('map'); the server compares, never the CLI.
|
|
197
|
+
export async function getProject(baseUrl, projectId) {
|
|
198
|
+
const res = await request(`${baseUrl}/api/projects/${encodeURIComponent(projectId)}`);
|
|
199
|
+
if (res.status === 404) throw new ApiError('Project not found', 404);
|
|
200
|
+
await handleResponse(res);
|
|
201
|
+
return (await res.json()).project;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function createProject(baseUrl, mapIds, name) {
|
|
205
|
+
return (await postJson(`${baseUrl}/api/projects`, { mapIds, ...(name ? { name } : {}) })).project;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function addProjectMap(baseUrl, projectId, mapId, lockPasswordHash) {
|
|
209
|
+
return (await postJson(`${baseUrl}/api/projects/${encodeURIComponent(projectId)}/maps`, { mapId, ...(lockPasswordHash ? { lockPasswordHash } : {}) })).project;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Checks the project password, answering 403 when it's wrong
|
|
213
|
+
export async function checkProjectPassword(baseUrl, projectId, passwordHash) {
|
|
214
|
+
return (await postJson(`${baseUrl}/api/projects/${encodeURIComponent(projectId)}/unlock`, { passwordHash })).project;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function lockProject(baseUrl, projectId, passwordHash, lockMaps) {
|
|
218
|
+
return postJson(`${baseUrl}/api/projects/${encodeURIComponent(projectId)}/lock`, { passwordHash, lockMaps: !!lockMaps });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function removeProjectLock(baseUrl, projectId, passwordHash, removeMaps) {
|
|
222
|
+
return (await postJson(`${baseUrl}/api/projects/${encodeURIComponent(projectId)}/lock/remove`, { passwordHash, removeMaps: !!removeMaps })).project;
|
|
223
|
+
}
|
|
224
|
+
|
|
153
225
|
export { ApiError };
|
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();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Storymaps CLI — AGPL-3.0 — see LICENCE for details
|
|
2
|
+
// The line-by-line diff that storymaps diff --full prints
|
|
3
|
+
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
|
|
6
|
+
// Prints the changed lines of a diffLines result, with three lines around each change
|
|
7
|
+
export function printFullDiff(changes) {
|
|
8
|
+
const CONTEXT = 3;
|
|
9
|
+
const lines = [];
|
|
10
|
+
let oldLine = 1;
|
|
11
|
+
let newLine = 1;
|
|
12
|
+
for (const part of changes) {
|
|
13
|
+
for (const text of part.value.replace(/\n$/, '').split('\n')) {
|
|
14
|
+
if (part.added) {
|
|
15
|
+
lines.push({ tag: '+', text, oldNum: null, newNum: newLine++ });
|
|
16
|
+
} else if (part.removed) {
|
|
17
|
+
lines.push({ tag: '-', text, oldNum: oldLine++, newNum: null });
|
|
18
|
+
} else {
|
|
19
|
+
lines.push({ tag: ' ', text, oldNum: oldLine++, newNum: newLine++ });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const width = String(Math.max(oldLine, newLine)).length;
|
|
25
|
+
const pad = n => n === null ? ' '.repeat(width) : String(n).padStart(width);
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
let lastPrinted = -1;
|
|
29
|
+
for (let i = 0; i < lines.length; i++) {
|
|
30
|
+
const nearby = lines.slice(Math.max(0, i - CONTEXT), i + CONTEXT + 1);
|
|
31
|
+
if (!nearby.some(l => l.tag !== ' ')) continue;
|
|
32
|
+
if (lastPrinted !== -1 && i - lastPrinted > 1) {
|
|
33
|
+
console.log(chalk.dim(`${'·'.repeat(width * 2 + 5)}`));
|
|
34
|
+
}
|
|
35
|
+
const { tag, text, oldNum, newNum } = lines[i];
|
|
36
|
+
const num = chalk.dim(`${pad(oldNum)} ${pad(newNum)}`);
|
|
37
|
+
if (tag === '+') console.log(`${num} ${chalk.green(`+ ${text}`)}`);
|
|
38
|
+
else if (tag === '-') console.log(`${num} ${chalk.red(`- ${text}`)}`);
|
|
39
|
+
else console.log(`${num} ${chalk.dim(` ${text}`)}`);
|
|
40
|
+
lastPrinted = i;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/lib/util/identity.js
CHANGED
|
@@ -9,26 +9,47 @@ const DEFAULT_SITE = 'storymaps.io';
|
|
|
9
9
|
function siteToBaseUrl(site) {
|
|
10
10
|
if (!site) return `https://${DEFAULT_SITE}`;
|
|
11
11
|
if (site === 'localhost') return 'http://localhost:3000';
|
|
12
|
+
// A local server on another port says so, such as localhost:3312
|
|
13
|
+
if (/^(localhost|127\.0\.0\.1)(:\d+)?$/.test(site)) return `http://${site}`;
|
|
12
14
|
return `https://${site}`;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
export { siteToBaseUrl };
|
|
18
|
+
|
|
19
|
+
// A link or domain/path, as a URL and its server, or null for a bare ID or file name
|
|
20
|
+
function parseLink(arg) {
|
|
21
|
+
if (!arg) return null;
|
|
22
|
+
if (/^https?:\/\//i.test(arg)) {
|
|
18
23
|
const url = new URL(arg);
|
|
19
|
-
|
|
20
|
-
if (!id) throw new Error(`Could not extract map ID from URL: ${arg}`);
|
|
21
|
-
const baseUrl = `${url.protocol}//${url.host}`;
|
|
22
|
-
return { id, baseUrl };
|
|
24
|
+
return { url, baseUrl: `${url.protocol}//${url.host}` };
|
|
23
25
|
}
|
|
24
|
-
|
|
25
|
-
// domain/id: storymaps.io/abc123
|
|
26
|
-
if (arg && arg.includes('/')) {
|
|
26
|
+
if (arg.includes('/') && !/\.(ya?ml|json)$/i.test(arg)) {
|
|
27
27
|
const url = new URL(`https://${arg}`);
|
|
28
|
-
|
|
28
|
+
return { url, baseUrl: siteToBaseUrl(url.hostname) };
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A project link: storymaps.io/p/<project> or storymaps.io/p/<project>/<map>.
|
|
34
|
+
// Returns { projectId, mapId (or null), baseUrl }, or null when the target isn't a project link.
|
|
35
|
+
export function parseProjectLink(arg) {
|
|
36
|
+
const link = parseLink(arg);
|
|
37
|
+
if (!link) return null;
|
|
38
|
+
const parts = link.url.pathname.split('/').filter(Boolean);
|
|
39
|
+
if (parts[0] !== 'p' || parts.length < 2 || parts.length > 3) return null;
|
|
40
|
+
return { projectId: parts[1], mapId: parts[2] || null, baseUrl: link.baseUrl };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function resolveTarget(arg, localFilePath = 'storymap.yml') {
|
|
44
|
+
const link = parseLink(arg);
|
|
45
|
+
if (link) {
|
|
46
|
+
const project = parseProjectLink(arg);
|
|
47
|
+
if (project && !project.mapId) {
|
|
48
|
+
throw new Error(`That's a link to a whole project, not one map. Add the map to the link, such as ${arg.replace(/\/+$/, '')}/<map ID>, or run this in the project's folder.`);
|
|
49
|
+
}
|
|
50
|
+
const id = project ? project.mapId : link.url.pathname.split('/').filter(Boolean).pop();
|
|
29
51
|
if (!id) throw new Error(`Could not extract map ID from: ${arg}`);
|
|
30
|
-
|
|
31
|
-
return { id, baseUrl };
|
|
52
|
+
return { id, baseUrl: link.baseUrl, ...(project ? { projectId: project.projectId } : {}) };
|
|
32
53
|
}
|
|
33
54
|
|
|
34
55
|
// Bare ID or no arg — read local file for site context
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Storymaps CLI — AGPL-3.0 — see LICENCE for details
|
|
2
|
+
// Passwords for a project and the maps in it, each checked with the server before anything is sent
|
|
3
|
+
//
|
|
4
|
+
// A password is tried in this order: remembered on this computer after an unlock, then
|
|
5
|
+
// STORYMAPS_PASSWORD, then typed in a terminal. Across a whole project, STORYMAPS_PASSWORD is only
|
|
6
|
+
// tried on the project and the maps locked with its password: a map with its own lock is never
|
|
7
|
+
// sent a guess that would count against the limit on wrong passwords.
|
|
8
|
+
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { readCache, writeCache } from './file.js';
|
|
11
|
+
import { askPassword, envPasswordHash, isInteractive, StopError, PASSWORD_ENV } from './prompt.js';
|
|
12
|
+
import { checkProjectPassword, unlockMap, ApiError } from './api.js';
|
|
13
|
+
|
|
14
|
+
const projectKey = (projectId) => `project-${projectId}`;
|
|
15
|
+
|
|
16
|
+
export async function rememberProjectPassword(projectId, hash) {
|
|
17
|
+
await writeCache(projectKey(projectId), { hash });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function forgetProjectPassword(projectId) {
|
|
21
|
+
await writeCache(projectKey(projectId), { hash: null });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const wrongPassword = (err) => err instanceof ApiError && err.status === 403;
|
|
25
|
+
|
|
26
|
+
async function projectPasswordWorks(baseUrl, projectId, hash) {
|
|
27
|
+
try {
|
|
28
|
+
await checkProjectPassword(baseUrl, projectId, hash);
|
|
29
|
+
return true;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (wrongPassword(err)) return false;
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function mapPasswordWorks(baseUrl, mapId, hash) {
|
|
37
|
+
try {
|
|
38
|
+
await unlockMap(baseUrl, mapId, hash);
|
|
39
|
+
return true;
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (wrongPassword(err)) return false;
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The project password, or null when the project isn't locked. reason is what the password is
|
|
47
|
+
// for, such as "Storymaps.io is locked. Enter its password to add Roadmap".
|
|
48
|
+
export async function projectPassword(baseUrl, project, reason) {
|
|
49
|
+
if (!project.locked) return null;
|
|
50
|
+
const saved = (await readCache(projectKey(project.id))).hash;
|
|
51
|
+
if (saved && await projectPasswordWorks(baseUrl, project.id, saved)) return saved;
|
|
52
|
+
|
|
53
|
+
const fromEnv = envPasswordHash();
|
|
54
|
+
if (fromEnv) {
|
|
55
|
+
if (await projectPasswordWorks(baseUrl, project.id, fromEnv)) return fromEnv;
|
|
56
|
+
throw new StopError(`${PASSWORD_ENV} isn't the password for ${project.name}. Nothing was changed.`);
|
|
57
|
+
}
|
|
58
|
+
if (!isInteractive()) {
|
|
59
|
+
throw new StopError(`${reason.replace(/\. Enter its password.*$/, '')}, and there's no terminal to type its password in. Nothing was changed. Run storymaps project unlock --maps in a terminal first, or set ${PASSWORD_ENV} for this command.`);
|
|
60
|
+
}
|
|
61
|
+
console.log(chalk.yellow(`${reason}.`));
|
|
62
|
+
const hash = await askPassword({ allowEnv: false });
|
|
63
|
+
if (!await projectPasswordWorks(baseUrl, project.id, hash)) throw new StopError('Incorrect password. Nothing was changed.');
|
|
64
|
+
return hash;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The password of a map with its own lock, across a whole project: remembered, or typed
|
|
68
|
+
export async function ownMapPassword(baseUrl, map, file) {
|
|
69
|
+
const saved = (await readCache(map.id)).hash;
|
|
70
|
+
if (saved && await mapPasswordWorks(baseUrl, map.id, saved)) return saved;
|
|
71
|
+
const unlockHint = file ? `storymaps unlock -f ${file}` : `storymaps unlock ${map.id}`;
|
|
72
|
+
if (!isInteractive()) {
|
|
73
|
+
throw new StopError(`${map.name} has its own lock, and there's no terminal to type its password in. Nothing was changed. Run ${unlockHint} in a terminal first.`);
|
|
74
|
+
}
|
|
75
|
+
console.log(chalk.yellow(`${map.name} has its own lock.`));
|
|
76
|
+
const hash = await askPassword({ allowEnv: false });
|
|
77
|
+
if (!await mapPasswordWorks(baseUrl, map.id, hash)) throw new StopError(`Incorrect password for ${map.name}. Nothing was changed.`);
|
|
78
|
+
return hash;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The password for one map, outside a whole-project command: remembered, STORYMAPS_PASSWORD, or typed
|
|
82
|
+
export async function singleMapPassword(baseUrl, mapId) {
|
|
83
|
+
const saved = (await readCache(mapId)).hash;
|
|
84
|
+
if (saved && await mapPasswordWorks(baseUrl, mapId, saved)) return saved;
|
|
85
|
+
const fromEnv = envPasswordHash();
|
|
86
|
+
if (fromEnv) {
|
|
87
|
+
if (await mapPasswordWorks(baseUrl, mapId, fromEnv)) return fromEnv;
|
|
88
|
+
throw new StopError(`${PASSWORD_ENV} isn't the password for this map. Nothing was changed.`);
|
|
89
|
+
}
|
|
90
|
+
if (!isInteractive()) {
|
|
91
|
+
throw new StopError(`This map is locked, and there's no terminal to type its password in. Nothing was changed. Run storymaps unlock in a terminal first, or set ${PASSWORD_ENV} for this command.`);
|
|
92
|
+
}
|
|
93
|
+
console.log(chalk.yellow('Map is locked.'));
|
|
94
|
+
const hash = await askPassword({ allowEnv: false });
|
|
95
|
+
if (!await mapPasswordWorks(baseUrl, mapId, hash)) throw new StopError('Incorrect password. Nothing was changed.');
|
|
96
|
+
return hash;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Lists names as "A", "A and B", or "A, B and C"
|
|
100
|
+
export const listNames = (names) => names.length <= 1 ? (names[0] || '')
|
|
101
|
+
: `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Storymaps CLI — AGPL-3.0 — see LICENCE for details
|
|
2
|
+
// Project folders: a folder of map files that share a project key
|
|
3
|
+
//
|
|
4
|
+
// A project lives in one folder, storymaps/ by default, so a repo gets one new folder rather than
|
|
5
|
+
// a file per map. Each file is an ordinary map file named by its map ID, such as
|
|
6
|
+
// storymaps/1g2pt83x.yml, so renaming a map in the app never puts a file out of step. There's no
|
|
7
|
+
// separate project file: the project name and tab order come from the server each time.
|
|
8
|
+
//
|
|
9
|
+
// The CLI never moves, renames or deletes a file, so a map open in an editor never looks
|
|
10
|
+
// deleted. The one exception is a map started offline whose ID turns out to be taken.
|
|
11
|
+
|
|
12
|
+
import { existsSync, statSync } from 'node:fs';
|
|
13
|
+
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
14
|
+
import { join, resolve, relative, extname, basename } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
import jsyaml from 'js-yaml';
|
|
18
|
+
import { siteToBaseUrl } from './identity.js';
|
|
19
|
+
import { StopError } from './prompt.js';
|
|
20
|
+
|
|
21
|
+
export const PROJECT_DIR = 'storymaps';
|
|
22
|
+
const MAP_FILE = /\.ya?ml$/i;
|
|
23
|
+
|
|
24
|
+
// A path as it reads from where the command was run, such as storymaps/1g2pt83x.yml
|
|
25
|
+
export const shortPath = (path) => relative(process.cwd(), path) || '.';
|
|
26
|
+
|
|
27
|
+
export const mapFilePath = (dir, mapId) => join(dir, `${mapId}.yml`);
|
|
28
|
+
|
|
29
|
+
// The map files in a folder that say which project they're in
|
|
30
|
+
export async function readProjectFiles(dir) {
|
|
31
|
+
let names;
|
|
32
|
+
try {
|
|
33
|
+
names = await readdir(dir);
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const files = [];
|
|
38
|
+
for (const name of names.sort()) {
|
|
39
|
+
if (!MAP_FILE.test(name)) continue;
|
|
40
|
+
const path = resolve(dir, name);
|
|
41
|
+
let content, obj;
|
|
42
|
+
try {
|
|
43
|
+
content = await readFile(path, 'utf-8');
|
|
44
|
+
obj = jsyaml.load(content);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// A file that names a project but can't be read is reported, never quietly skipped
|
|
47
|
+
if (content && /^project:/m.test(content)) files.push({ path, name, content, broken: err.message.split('\n')[0] });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!obj || typeof obj !== 'object' || !obj.project) continue;
|
|
51
|
+
files.push({ path, name, content, obj, id: obj.id ? String(obj.id) : null, project: String(obj.project), site: obj.site });
|
|
52
|
+
}
|
|
53
|
+
return files;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The folder a project is pulled or started in, when --dir doesn't say: the current folder if
|
|
57
|
+
// it's already a project folder (named storymaps, or holding project files), so running a
|
|
58
|
+
// command from inside storymaps/ never makes storymaps/storymaps/; otherwise storymaps/ in it.
|
|
59
|
+
export async function defaultProjectDir(dir) {
|
|
60
|
+
if (dir) return resolve(dir);
|
|
61
|
+
const cwd = process.cwd();
|
|
62
|
+
if (existsSync(join(cwd, PROJECT_DIR))) return join(cwd, PROJECT_DIR);
|
|
63
|
+
if (basename(cwd) === PROJECT_DIR || (await readProjectFiles(cwd)).length) return cwd;
|
|
64
|
+
return join(cwd, PROJECT_DIR);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The project folder a command works on, or null when it works on one map. A project in reach
|
|
68
|
+
// wins: the folder named with --dir, storymaps/ inside the current folder, or project files in the
|
|
69
|
+
// current folder itself, so commands work from a repo root or from inside storymaps/. With no
|
|
70
|
+
// project in reach, storymap.yml means one map, exactly as before projects. -f always means one map.
|
|
71
|
+
export async function findProjectFolder(opts = {}) {
|
|
72
|
+
if (opts.file) return null;
|
|
73
|
+
const candidates = opts.dir ? [opts.dir] : [PROJECT_DIR, '.'];
|
|
74
|
+
for (const candidate of candidates) {
|
|
75
|
+
const dir = resolve(candidate);
|
|
76
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) continue;
|
|
77
|
+
const files = await readProjectFiles(dir);
|
|
78
|
+
if (!files.length) continue;
|
|
79
|
+
const projects = [...new Set(files.filter(f => !f.broken).map(f => f.project))];
|
|
80
|
+
if (!projects.length) throw new StopError(`${files.map(f => f.name).join(', ')} in ${shortPath(dir)} can't be read: ${files[0].broken}`);
|
|
81
|
+
if (projects.length > 1) {
|
|
82
|
+
throw new StopError(`${shortPath(dir)} has maps from ${projects.length} projects: ${projects.join(', ')}. Use --dir for a folder with one project, or -f for one map.`);
|
|
83
|
+
}
|
|
84
|
+
const site = files.find(f => f.site)?.site;
|
|
85
|
+
// A storymap.yml beside the project is still one -f away; say so, so it isn't missed
|
|
86
|
+
const single = resolve('storymap.yml');
|
|
87
|
+
if (!opts.dir && !opts.quiet && existsSync(single) && !files.some(f => f.path === single)) {
|
|
88
|
+
console.log(chalk.dim(`Using the project in ${candidate === '.' ? 'this folder' : `${candidate}/`}. Add -f storymap.yml for the map in storymap.yml.`));
|
|
89
|
+
}
|
|
90
|
+
return { dir, files, projectId: projects[0], baseUrl: siteToBaseUrl(site), site };
|
|
91
|
+
}
|
|
92
|
+
// A folder named with --dir is never quietly passed over, so a typo can't look like it worked
|
|
93
|
+
if (opts.dir) {
|
|
94
|
+
const dir = resolve(opts.dir);
|
|
95
|
+
if (!existsSync(dir)) throw new StopError(`There's no folder called ${opts.dir}.`);
|
|
96
|
+
throw new StopError(`${opts.dir} has no map files from a project. Pull a project into it with storymaps pull storymaps.io/p/<project> --dir ${opts.dir}.`);
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Puts project: <id> straight after id: (or name:) in a file's text, replacing any it has
|
|
102
|
+
export function setProjectKey(content, projectId) {
|
|
103
|
+
const without = removeProjectKey(content);
|
|
104
|
+
if (/^id:\s.*$/m.test(without)) return without.replace(/^(id:\s.*)$/m, `$1\nproject: ${projectId}`);
|
|
105
|
+
if (/^name:\s.*$/m.test(without)) return without.replace(/^(name:\s.*)$/m, `$1\nproject: ${projectId}`);
|
|
106
|
+
return `project: ${projectId}\n${without}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function removeProjectKey(content) {
|
|
110
|
+
return content.replace(/^project:[^\n]*\n?/m, '');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Puts id: <id> straight after name:, replacing any it has
|
|
114
|
+
export function setIdKey(content, id) {
|
|
115
|
+
const without = content.replace(/^id:[^\n]*\n?/m, '');
|
|
116
|
+
if (/^name:\s.*$/m.test(without)) return without.replace(/^(name:\s.*)$/m, `$1\nid: ${id}`);
|
|
117
|
+
return `id: ${id}\n${without}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Puts site: <site> after project: (or id:), unless the file already says its site
|
|
121
|
+
export function setSiteKey(content, site) {
|
|
122
|
+
if (!site || /^site:\s/m.test(content)) return content;
|
|
123
|
+
for (const key of ['project', 'id', 'name']) {
|
|
124
|
+
const re = new RegExp(`^(${key}:\\s.*)$`, 'm');
|
|
125
|
+
if (re.test(content)) return content.replace(re, `$1\nsite: ${site}`);
|
|
126
|
+
}
|
|
127
|
+
return `site: ${site}\n${content}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The site a server's address stands for in a map file, such as storymaps.io or localhost:3312
|
|
131
|
+
export const siteOfBaseUrl = (baseUrl) => {
|
|
132
|
+
const url = new URL(baseUrl);
|
|
133
|
+
return url.host === 'localhost:3000' ? 'localhost' : url.host;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Sets locked: true or false in a map file after the CLI locks or unlocks its map, so the file
|
|
137
|
+
// says what the map is now. Only that line changes; the conflict check doesn't cover it.
|
|
138
|
+
export async function setLockedInFile(path, locked) {
|
|
139
|
+
let content;
|
|
140
|
+
try {
|
|
141
|
+
content = await readFile(path, 'utf-8');
|
|
142
|
+
} catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!/^locked:\s/m.test(content)) return;
|
|
146
|
+
const updated = content.replace(/^locked:\s.*$/m, `locked: ${locked}`);
|
|
147
|
+
if (updated !== content) await writeFile(path, updated, 'utf-8');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// After a project lock changes, the files in the folder for the maps whose lock changed
|
|
151
|
+
export async function updateLockedInFolder(dir, before, after) {
|
|
152
|
+
if (!dir) return;
|
|
153
|
+
const files = await readProjectFiles(dir);
|
|
154
|
+
for (const map of after.maps) {
|
|
155
|
+
const was = !!before.maps.find(m => m.id === map.id)?.lock;
|
|
156
|
+
const now = !!map.lock;
|
|
157
|
+
if (was === now) continue;
|
|
158
|
+
const file = files.find(f => f.id === map.id);
|
|
159
|
+
if (file) await setLockedInFile(file.path, now);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function writeMapFile(path, content) {
|
|
164
|
+
await mkdir(resolve(path, '..'), { recursive: true });
|
|
165
|
+
await writeFile(path, content, 'utf-8');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const isMapFile = (path) => MAP_FILE.test(extname(path) ? path : '');
|
|
169
|
+
|
|
170
|
+
// The CLI's own notes, kept in ~/.storymaps next to the conflict-check data, never in the repo:
|
|
171
|
+
// newMaps: files written by project add for a map not yet on the server, so push knows to create
|
|
172
|
+
// it, and can tell it from a map that was deleted
|
|
173
|
+
// drafts: files project add started a map from, so push passes over them with a reminder
|
|
174
|
+
const NOTES = join(homedir(), '.storymaps', 'projects.json');
|
|
175
|
+
|
|
176
|
+
async function readNotes() {
|
|
177
|
+
try {
|
|
178
|
+
const notes = JSON.parse(await readFile(NOTES, 'utf-8'));
|
|
179
|
+
return { newMaps: notes.newMaps || {}, drafts: notes.drafts || {} };
|
|
180
|
+
} catch {
|
|
181
|
+
return { newMaps: {}, drafts: {} };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function writeNotes(notes) {
|
|
186
|
+
await mkdir(join(homedir(), '.storymaps'), { recursive: true });
|
|
187
|
+
await writeFile(NOTES, JSON.stringify(notes), 'utf-8');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function noteNewMap(path, id) {
|
|
191
|
+
const notes = await readNotes();
|
|
192
|
+
notes.newMaps[resolve(path)] = { id };
|
|
193
|
+
await writeNotes(notes);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function isNewMap(path, id) {
|
|
197
|
+
return (await readNotes()).newMaps[resolve(path)]?.id === id;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function forgetNewMap(path) {
|
|
201
|
+
const notes = await readNotes();
|
|
202
|
+
delete notes.newMaps[resolve(path)];
|
|
203
|
+
await writeNotes(notes);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function noteDraft(draftPath, newPath) {
|
|
207
|
+
const notes = await readNotes();
|
|
208
|
+
notes.drafts[resolve(draftPath)] = { file: resolve(newPath) };
|
|
209
|
+
await writeNotes(notes);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The file a draft became, or null
|
|
213
|
+
export async function draftOf(path) {
|
|
214
|
+
return (await readNotes()).drafts[resolve(path)]?.file || null;
|
|
215
|
+
}
|