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 CHANGED
@@ -48,6 +48,15 @@ npx storymaps pull --force storymaps.io/abc123 && claude "The storymap file @sto
48
48
 
49
49
  From there you steer the agent a slice or a feature at a time ("now build search from the V1 slice"). When a feature has been through QA, set its cards to `status: done` in `storymap.yml` and `storymaps push`, so the map shows the progress to everyone else. Pull again before the next round to pick up changes made in the browser.
50
50
 
51
+ For a [project](#projects) of several maps, pull the whole project, so the agent gets every map as one specification in `storymaps/`:
52
+
53
+ ```bash
54
+ storymaps pull storymaps.io/p/k8f2m9xq # every map, into storymaps/
55
+ storymaps project unlock --maps # if it's locked: once, in your terminal
56
+ ```
57
+
58
+ Unlocking once remembers the password on this computer, so the agent can push without ever seeing it. Agents should pass `--yes` to take default answers, as some agent tools run commands in a terminal where a question would wait. In CI, give the password with `STORYMAPS_PASSWORD` instead.
59
+
51
60
  ## Quick Start
52
61
 
53
62
  ```bash
@@ -69,6 +78,82 @@ storymaps diff # see what changed
69
78
  storymaps push # sync to the server
70
79
  ```
71
80
 
81
+ ## Projects
82
+
83
+ A project groups several story maps, such as one per team or per part of a product. The CLI keeps a whole project in one `storymaps/` folder, one file per map, named by map ID so renaming a map in the app never puts a file out of step:
84
+
85
+ ```bash
86
+ storymaps pull storymaps.io/p/k8f2m9xq # every map in the project, into storymaps/
87
+ storymaps status # one line per map, and a total
88
+ storymaps push # checks every map first, then pushes the ones that changed
89
+ ```
90
+
91
+ ```
92
+ my-repo/
93
+ └── storymaps/
94
+ ├── 1g2pt83x.yml CLI
95
+ ├── wtuzo7nw.yml Web App
96
+ └── 8166fkm0.yml MCP Server
97
+ ```
98
+
99
+ Each file says which project it's in, straight after its `id`:
100
+
101
+ ```yaml
102
+ name: CLI
103
+ id: 1g2pt83x
104
+ project: k8f2m9xq
105
+ site: storymaps.io
106
+ ```
107
+
108
+ - **One rule.** `storymaps <command>` works on maps: one map with `-f` or a link, or every map in the project folder without one. `storymaps project <command>` changes the project itself.
109
+ - **A link to one map in a project** asks the first time whether you want just that map or the whole project, since the app's address bar always shows a map inside its project.
110
+ - **Push checks everything before it sends anything**: every file, every conflict and every password. A project never ends up half pushed. A file this computer never pulled, such as one from a fresh clone, stops push until you pull.
111
+ - **The CLI never moves, renames or deletes your files**, so a map open in an editor never looks deleted. The one exception is a map started offline whose ID turns out to be taken by another map: it gets a fresh ID, and its file is renamed to match.
112
+ - **A map removed from the project in the app** keeps its file, and pull takes its `project` key out. Push never adds a map back by accident.
113
+ - **A project in reach wins**: `storymaps/` below you, or project files in the current folder, so commands work from the repo root. A `storymap.yml` beside a project is one `-f storymap.yml` away, and the command says so. A folder with only `storymap.yml` works exactly as before.
114
+
115
+ ### `project init`
116
+
117
+ ```bash
118
+ storymaps project init --name "Coffee shop" # a new project with a first map
119
+ storymaps project init --name "Coffee shop" ordering.yml loyalty.yml # a map from each file
120
+ ```
121
+
122
+ The files you start from are left as they are, for you to delete.
123
+
124
+ ### `project add`
125
+
126
+ ```bash
127
+ storymaps project add --name "Roadmap" # a new map, even offline
128
+ storymaps project add storymap.yml # a map from outside the project
129
+ storymaps project add storymaps.io/abc123 # an existing map, by link or ID
130
+ storymaps project add --project storymaps.io/p/k8f2m9xq # with no project folder yet
131
+ ```
132
+
133
+ Online, the map is created and added straight away. Offline, the CLI makes the map ID and the next `push` creates the map and adds it.
134
+
135
+ ### `project lock` / `project unlock`
136
+
137
+ ```bash
138
+ storymaps project lock # the project, or the project and its maps: the same two choices as the app
139
+ storymaps project lock --maps # include the maps that aren't locked yet, without asking
140
+ storymaps project unlock # on this computer
141
+ storymaps project unlock --maps # and the maps that use the project password
142
+ storymaps project unlock --remove # for everyone
143
+ ```
144
+
145
+ A map with its own password keeps it. Renaming a project, and reordering, removing and deleting its maps, stay in the app.
146
+
147
+ ### Questions, scripts and agents
148
+
149
+ The CLI only asks a question in a terminal. `-y, --yes` takes the default answers, and `--force` answers the questions that guard against overwriting or removing something. With no terminal and no flag, a question about how much to do takes its default and says so, and a guard stops and names the flag.
150
+
151
+ Passwords are never flags. They're typed, remembered on this computer after `storymaps unlock` or `storymaps project unlock --maps`, or taken from the `STORYMAPS_PASSWORD` environment variable, for scripts and CI:
152
+
153
+ ```bash
154
+ STORYMAPS_PASSWORD="$RELEASE_PASSWORD" storymaps project lock --maps
155
+ ```
156
+
72
157
  ## Status
73
158
 
74
159
  See the project status in a snapshot:
@@ -98,6 +183,24 @@ Blocked
98
183
 
99
184
  Non-task cards (notes, questions) are excluded from counts based on the legend.
100
185
 
186
+ In a project folder, `status` shows one line per map, in tab order, with its file and its lock, and a total:
187
+
188
+ ```bash
189
+ > storymaps status
190
+
191
+ Coffee shop
192
+ https://storymaps.io/p/k8f2m9xq
193
+ project locked
194
+
195
+ Ordering rq4m8z2c.yml ████████████████░░░░ 12/15 (80%) locked
196
+ Loyalty bt7k1w9e.yml ██████░░░░░░░░░░░░░░ 4/13 (31%) locked
197
+ Barista hx2p6n0d.yml ░░░░░░░░░░░░░░░░░░░░ 0/9 (0%) own lock
198
+
199
+ Total ███████░░░░░░░░░░░░░ 16/37 stories (43%)
200
+ ```
201
+
202
+ `locked` is a map locked with the project password, and `own lock` a map with its own password.
203
+
101
204
  ## Commands
102
205
 
103
206
  ### `init`
@@ -141,6 +244,7 @@ If the file has no `id`, a new map is created on the server. Conflicts are detec
141
244
  storymaps diff # summary of changes vs remote
142
245
  storymaps diff --full # full line-by-line diff
143
246
  storymaps diff file1.yml file2.yml # compare two local files
247
+ storymaps diff storymaps.io/abc123 # the local file of that map, compared with the server
144
248
  ```
145
249
 
146
250
  ### `status`
@@ -225,7 +329,7 @@ storymaps validate storymap.yml # check YAML structure
225
329
 
226
330
  A target can be a map ID, `domain/id`, or a full URL, including a map inside a project link (`storymaps.io/p/<project>/<map>`).
227
331
 
228
- `pull`, `push`, `open`, `lock`, `unlock`, `status`, `log`, `backup`, `backups` and `restore` take `-f, --file <path>` to use a file other than `storymap.yml`.
332
+ `pull`, `push`, `open`, `lock`, `unlock`, `status`, `log`, `backup`, `backups` and `restore` take `-f, --file <path>` to use a file other than `storymap.yml`. In a project folder, `pull`, `push`, `status`, `diff`, `open`, `backup` and `backups` work on every map without `-f`, and take `--dir <folder>` for a project folder other than `storymaps`. `validate storymaps` checks every map file in the folder, and `restore` finds which map a backup ID belongs to.
229
333
 
230
334
  ### `convert`
231
335
 
package/bin/storymaps.js CHANGED
@@ -18,17 +18,21 @@ import { unlockCommand } from '../lib/commands/unlock.js';
18
18
  import { statusCommand } from '../lib/commands/status.js';
19
19
  import { logCommand } from '../lib/commands/log.js';
20
20
  import { backupCommand, backupsCommand, restoreCommand } from '../lib/commands/backup.js';
21
+ import { projectInitCommand, projectAddCommand, projectLockCommand, projectUnlockCommand } from '../lib/commands/project.js';
21
22
 
22
23
 
23
24
  const program = new Command();
24
25
 
25
26
  program
26
27
  .name('storymaps')
27
- .description('CLI companion for storymaps.io — manage user story maps from the terminal')
28
+ .description('CLI companion for storymaps.io: manage user story maps from the terminal')
28
29
  .version(pkg.version)
29
30
  .showHelpAfterError()
30
31
  .addHelpText('after', '\nRun storymaps <command> --help for details on a specific command.');
31
32
 
33
+ const FILE_HELP = 'one map\'s file (default: storymap.yml, or every map in a project folder)';
34
+ const DIR_HELP = 'the project folder (default: storymaps)';
35
+
32
36
  program
33
37
  .command('init')
34
38
  .description('Create a new storymap.yml in the current directory')
@@ -37,8 +41,8 @@ program
37
41
 
38
42
  program
39
43
  .command('validate')
40
- .description('Validate a storymap YAML or JSON file')
41
- .argument('<file>', 'path to storymap file')
44
+ .description('Validate a storymap YAML or JSON file, or every map file in a folder')
45
+ .argument('<file>', 'path to a storymap file, or a folder such as storymaps')
42
46
  .action(validateCommand);
43
47
 
44
48
  program
@@ -52,77 +56,85 @@ program
52
56
 
53
57
  program
54
58
  .command('pull')
55
- .description('Download a map from the remote server')
56
- .argument('[target]', 'map ID, domain/ID, or full URL')
57
- .option('-f, --file <path>', 'local file path', 'storymap.yml')
58
- .option('--force', 'overwrite local file without prompting')
59
+ .description('Download a map, or a whole project, from the remote server')
60
+ .argument('[target]', 'map ID, domain/ID, full URL, or a project link such as storymaps.io/p/<project>')
61
+ .option('-f, --file <path>', FILE_HELP)
62
+ .option('--dir <folder>', DIR_HELP)
63
+ .option('--force', 'overwrite local files without asking')
64
+ .option('-y, --yes', 'take the default answers, such as just the map for a link to a map in a project')
59
65
  .action(pullCommand);
60
66
 
61
67
  program
62
68
  .command('push')
63
- .description('Upload a local map to the remote server')
69
+ .description('Upload a local map, or every map in a project folder, to the remote server')
64
70
  .argument('[target]', 'map ID, domain/ID, or full URL')
65
- .option('-f, --file <path>', 'local file path', 'storymap.yml')
71
+ .option('-f, --file <path>', FILE_HELP)
72
+ .option('--dir <folder>', DIR_HELP)
66
73
  .option('--force', 'skip conflict check and overwrite remote')
67
74
  .action(pushCommand);
68
75
 
69
76
  program
70
77
  .command('diff')
71
- .description('Compare a local file against the remote or another local file')
72
- .argument('[source]', 'local file to compare (default: storymap.yml)')
78
+ .description('Compare a local file against the remote or another local file, or every map in a project folder')
79
+ .argument('[source]', 'local file to compare, or a map link or ID to compare its local file (default: storymap.yml)')
73
80
  .argument('[target]', 'second local file (omit to compare against remote)')
74
81
  .option('--full', 'show the full file instead of just changed regions')
82
+ .option('--dir <folder>', DIR_HELP)
75
83
  .action(diffCommand);
76
84
 
77
85
  program
78
86
  .command('open')
79
- .description('Open a map in the default browser')
87
+ .description('Open a map, or a project, in the default browser')
80
88
  .argument('[target]', 'map ID, domain/ID, or full URL')
81
- .option('-f, --file <path>', 'local file path', 'storymap.yml')
89
+ .option('-f, --file <path>', FILE_HELP)
90
+ .option('--dir <folder>', DIR_HELP)
82
91
  .action(openCommand);
83
92
 
84
93
  program
85
94
  .command('lock')
86
- .description('Password-protect a map')
95
+ .description('Password-protect a map. For a whole project, use storymaps project lock')
87
96
  .argument('[target]', 'map ID, domain/ID, or full URL')
88
- .option('-f, --file <path>', 'local file path', 'storymap.yml')
97
+ .option('-f, --file <path>', 'local file path (default: storymap.yml)')
89
98
  .action(lockCommand);
90
99
 
91
100
  program
92
101
  .command('unlock')
93
- .description('Unlock a password-protected map')
102
+ .description('Unlock a password-protected map. For a whole project, use storymaps project unlock')
94
103
  .argument('[target]', 'map ID, domain/ID, or full URL')
95
- .option('-f, --file <path>', 'local file path', 'storymap.yml')
104
+ .option('-f, --file <path>', 'local file path (default: storymap.yml)')
96
105
  .option('--remove', 'permanently remove the lock instead of session unlock')
97
106
  .action(unlockCommand);
98
107
 
99
108
  program
100
109
  .command('status')
101
- .description('Show a progress overview of the story map')
102
- .argument('[target]', 'storymap file, or a map ID, domain/ID, or full URL')
103
- .option('-f, --file <path>', 'path to storymap file', 'storymap.yml')
110
+ .description('Show a progress overview of the story map, or of every map in a project')
111
+ .argument('[target]', 'storymap file, a map ID, domain/ID, full URL, or a project link')
112
+ .option('-f, --file <path>', FILE_HELP)
113
+ .option('--dir <folder>', DIR_HELP)
104
114
  .action(statusCommand);
105
115
 
106
116
  program
107
117
  .command('log')
108
118
  .description('Show activity log for a map')
109
119
  .argument('[target]', 'map ID, domain/ID, or full URL')
110
- .option('-f, --file <path>', 'local file path', 'storymap.yml')
120
+ .option('-f, --file <path>', 'local file path (default: storymap.yml)')
111
121
  .action(logCommand);
112
122
 
113
123
  program
114
124
  .command('backup')
115
- .description('Back up a map on the server')
125
+ .description('Back up a map, or every map in a project folder, on the server')
116
126
  .argument('[target]', 'map ID, domain/ID, or full URL')
117
- .option('-f, --file <path>', 'local file path', 'storymap.yml')
127
+ .option('-f, --file <path>', FILE_HELP)
128
+ .option('--dir <folder>', DIR_HELP)
118
129
  .option('-m, --message <note>', 'a note to tell the backup apart')
119
130
  .action(backupCommand);
120
131
 
121
132
  program
122
133
  .command('backups')
123
- .description('List the backups of a map, newest first')
134
+ .description('List the backups of a map, newest first, or the newest backup of each map in a project')
124
135
  .argument('[target]', 'map ID, domain/ID, or full URL')
125
- .option('-f, --file <path>', 'local file path', 'storymap.yml')
136
+ .option('-f, --file <path>', FILE_HELP)
137
+ .option('--dir <folder>', DIR_HELP)
126
138
  .action(backupsCommand);
127
139
 
128
140
  program
@@ -130,13 +142,64 @@ program
130
142
  .description('Restore a map from one of its backups, after backing up the map as it is now')
131
143
  .argument('<backup-id>', 'the ID of a backup, from storymaps backups')
132
144
  .argument('[target]', 'map ID, domain/ID, or full URL')
133
- .option('-f, --file <path>', 'local file path', 'storymap.yml')
145
+ .option('-f, --file <path>', 'local file path (default: storymap.yml, or the map the backup belongs to in a project folder)')
146
+ .option('--dir <folder>', DIR_HELP)
134
147
  .option('--force', 'restore without asking first')
135
148
  .action(restoreCommand);
136
149
 
150
+ // Commands that change a project itself. The other commands work on its maps.
151
+ const project = program
152
+ .command('project')
153
+ .description('Start a project, add maps to it, or lock and unlock it');
154
+
155
+ project
156
+ .command('init')
157
+ .description('Start a project with a first map, or with a map from each file, in storymaps/')
158
+ .argument('[files...]', 'map files to start the project from; they are left as they are')
159
+ .option('--name <name>', 'the project name (default: Untitled project)')
160
+ .option('--dir <folder>', DIR_HELP)
161
+ .option('-s, --site <site>', 'target site (e.g. localhost, storymaps.io)')
162
+ .action(projectInitCommand);
163
+
164
+ project
165
+ .command('add')
166
+ .description('Add a map to the project: a new map, a map from a file, or an existing map')
167
+ .argument('[map]', 'a map file, or an existing map by ID or link (default: a new blank map)')
168
+ .option('--name <name>', 'the name of a new map (default: My Story Map)')
169
+ .option('--dir <folder>', DIR_HELP)
170
+ .option('--project <link>', 'the project, by link or ID, when there is no project folder yet')
171
+ .action(projectAddCommand);
172
+
173
+ project
174
+ .command('lock')
175
+ .description('Lock the project, or the project and its maps')
176
+ .option('--maps', 'lock the maps that are not locked too, without asking')
177
+ .option('-y, --yes', 'take the default answer: the project alone')
178
+ .option('--dir <folder>', DIR_HELP)
179
+ .option('--project <link>', 'the project, by link or ID, when there is no project folder')
180
+ .action(projectLockCommand);
181
+
182
+ project
183
+ .command('unlock')
184
+ .description('Unlock the project on this computer, or remove its lock for everyone')
185
+ .option('--remove', 'remove the lock for everyone')
186
+ .option('--maps', 'also unlock, or with --remove remove the lock from, the maps that use the project password')
187
+ .option('-y, --yes', 'take the default answers: the project only, and the map locks kept')
188
+ .option('--dir <folder>', DIR_HELP)
189
+ .option('--project <link>', 'the project, by link or ID, when there is no project folder')
190
+ .action(projectUnlockCommand);
191
+
137
192
  program.parseAsync().catch(err => {
138
193
  const message = err.name === 'ExitPromptError' ? 'Cancelled.'
194
+ // fetch says only "fetch failed" when the server can't be reached
195
+ : err.message === 'fetch failed' ? `Couldn't reach the server${err.cause?.code ? ` (${err.cause.code})` : ''}. Check your connection, or the site in the map file, and try again.`
139
196
  : err.message || String(err);
197
+ // A stop the CLI chose says what to do in its own words
198
+ if (err.name === 'StopError') {
199
+ console.error(chalk.red(message.split('\n')[0]) + (message.includes('\n') ? `\n${message.split('\n').slice(1).join('\n')}` : ''));
200
+ process.exitCode = 1;
201
+ return;
202
+ }
140
203
  console.error(chalk.red(message));
141
204
  process.exitCode = 1;
142
205
  });
@@ -1,18 +1,27 @@
1
1
  // Storymaps CLI — AGPL-3.0 — see LICENCE for details
2
2
  // storymaps backup, backups and restore - make, list and restore backups of a map on the server
3
3
 
4
- import { createHash } from 'node:crypto';
5
4
  import { resolve } from 'node:path';
6
5
  import chalk from 'chalk';
7
- import { confirm, password } from '@inquirer/prompts';
8
6
  import { resolveTarget } from '../util/identity.js';
9
- import { createBackup, listBackups, getBackup, getLockStatus, unlockMap, putMap, ApiError } from '../util/api.js';
10
- import { readCache, writeCache } from '../util/file.js';
7
+ import { createBackup, listBackups, getBackup, getLockStatus, putMap } from '../util/api.js';
11
8
  import { formatTime } from '../util/time.js';
9
+ import { findProjectFolder, shortPath } from '../util/project.js';
10
+ import { singleMapPassword } from '../util/passwords.js';
11
+ import { guard } from '../util/prompt.js';
12
+ import { backupProject, backupsProject, findBackupMap } from './project-sync.js';
12
13
 
13
14
  const cards = (n) => `${n} card${n === 1 ? '' : 's'}`;
14
15
 
15
16
  export async function backupCommand(target, opts) {
17
+ // No -f in a project folder: every map, with the same note
18
+ if (!target) {
19
+ const ctx = await findProjectFolder(opts);
20
+ if (ctx?.projectId) {
21
+ await backupProject(ctx, opts);
22
+ return;
23
+ }
24
+ }
16
25
  const { id, baseUrl } = await resolveTarget(target, resolve(opts.file || 'storymap.yml'));
17
26
  const lockPasswordHash = await lockPassword(baseUrl, id);
18
27
  const backup = await createBackup(baseUrl, id, opts.message || '', { lockPasswordHash });
@@ -24,6 +33,14 @@ export async function backupCommand(target, opts) {
24
33
  }
25
34
 
26
35
  export async function backupsCommand(target, opts) {
36
+ // No -f in a project folder: the newest backup of each map
37
+ if (!target) {
38
+ const ctx = await findProjectFolder(opts);
39
+ if (ctx?.projectId) {
40
+ await backupsProject(ctx);
41
+ return;
42
+ }
43
+ }
27
44
  const { id, baseUrl } = await resolveTarget(target, resolve(opts.file || 'storymap.yml'));
28
45
  const backups = await listBackups(baseUrl, id);
29
46
 
@@ -41,47 +58,30 @@ export async function backupsCommand(target, opts) {
41
58
  console.log(chalk.dim(`\nRestore one with ${chalk.cyan('storymaps restore <backup ID>')}`));
42
59
  }
43
60
 
44
- // The lock password for a locked map, checked with the server before anything changes.
45
- // The saved one from storymaps unlock is tried first.
61
+ // The lock password for a locked map, checked with the server before anything changes:
62
+ // remembered after storymaps unlock, STORYMAPS_PASSWORD, or typed
46
63
  async function lockPassword(baseUrl, id) {
47
64
  const { isLocked } = await getLockStatus(baseUrl, id);
48
65
  if (!isLocked) return undefined;
49
-
50
- const saved = (await readCache(id)).hash;
51
- if (saved) {
52
- try {
53
- await unlockMap(baseUrl, id, saved);
54
- return saved;
55
- } catch (err) {
56
- if (!(err instanceof ApiError && err.status === 403)) throw err;
57
- }
58
- }
59
-
60
- console.log(chalk.yellow('Map is locked.'));
61
- const pw = await password({ theme: { prefix: '>' }, message: 'Password:' });
62
- const hash = createHash('sha256').update(pw).digest('hex');
63
- await unlockMap(baseUrl, id, hash);
64
- await writeCache(id, { hash });
65
- return hash;
66
+ return singleMapPassword(baseUrl, id);
66
67
  }
67
68
 
68
69
  export async function restoreCommand(backupId, target, opts) {
69
- const filePath = resolve(opts.file || 'storymap.yml');
70
+ // In a project folder, a backup ID belongs to one map, so the CLI finds which
71
+ let filePath = resolve(opts.file || 'storymap.yml');
72
+ if (!target && !opts.file) {
73
+ const ctx = await findProjectFolder(opts);
74
+ if (ctx?.projectId) filePath = (await findBackupMap(ctx, backupId)).path;
75
+ }
70
76
  const { id, baseUrl } = await resolveTarget(target, filePath);
71
77
  const backup = await getBackup(baseUrl, id, backupId);
72
78
  const note = backup.note ? `, "${backup.note}"` : '';
73
79
  console.log(`Backup ${backup.id} from ${formatTime(backup.timestamp)}: ${backup.mapName || 'Untitled'}, ${cards(backup.cardCount)}${note}`);
74
80
 
75
- if (!opts.force) {
76
- const ok = await confirm({
77
- message: 'Restore this backup? The map is backed up first.',
78
- default: true,
79
- theme: { prefix: '>' },
80
- });
81
- if (!ok) {
82
- console.log('Aborted.');
83
- return;
84
- }
81
+ const ok = await guard({ message: 'Restore this backup? The map is backed up first.', force: opts.force, stopMessage: 'Nothing was restored.' });
82
+ if (!ok) {
83
+ console.log('Aborted.');
84
+ return;
85
85
  }
86
86
 
87
87
  const lockPasswordHash = await lockPassword(baseUrl, id);
@@ -93,5 +93,7 @@ export async function restoreCommand(backupId, target, opts) {
93
93
  console.log(chalk.green(`Restored map ${id} from backup ${backup.id}`));
94
94
  console.log(chalk.dim(`The map before the restore is backup ${safety.id}.`));
95
95
  // The saved ETag is left alone, so a push from an old local file stops with a conflict
96
- console.log(`Run ${chalk.cyan('storymaps pull')} to bring the restored map into your local file.`);
96
+ // The local file isn't overwritten, as it may have changes not pushed yet
97
+ const into = target ? 'your local file' : shortPath(filePath);
98
+ console.log(`Run ${chalk.cyan('storymaps pull')} to bring the restored map into ${into}.`);
97
99
  }
@@ -2,15 +2,20 @@
2
2
  // storymaps diff — compare local file vs remote or two local files
3
3
 
4
4
  import { readFile } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
5
6
  import { resolve } from 'node:path';
6
7
  import chalk from 'chalk';
7
8
  import { diffLines } from 'diff';
8
9
  import jsyaml from 'js-yaml';
9
10
  import { exportToYaml, yamlObjToJson } from '../core/yaml.js';
10
11
  import { detectFormat } from '../util/file.js';
11
- import { resolveTarget } from '../util/identity.js';
12
+ import { resolveTarget, parseProjectLink } from '../util/identity.js';
13
+ import { StopError } from '../util/prompt.js';
12
14
  import { getMap } from '../util/api.js';
13
15
  import { printChangeSummary } from '../util/summary.js';
16
+ import { printFullDiff } from '../util/full-diff.js';
17
+ import { findProjectFolder } from '../util/project.js';
18
+ import { diffProject } from './project-sync.js';
14
19
 
15
20
  async function readAndNormalise(filePath) {
16
21
  const content = await readFile(filePath, 'utf-8');
@@ -25,8 +30,52 @@ async function readAndNormalise(filePath) {
25
30
  return exportToYaml(json);
26
31
  }
27
32
 
33
+ // The local file of the map a link or ID names: its file in the project folder, or storymap.yml
34
+ // when that's the map. A bare project link diffs the whole project folder. Returns null when
35
+ // it has already printed the result or said why it can't.
36
+ async function localFileFor(source, opts) {
37
+ const link = parseProjectLink(source);
38
+ const ctx = await findProjectFolder({ dir: opts.dir }).catch(() => null);
39
+ if (link && !link.mapId) {
40
+ if (ctx?.projectId === link.projectId) {
41
+ await diffProject(ctx, { full: opts.full });
42
+ return null;
43
+ }
44
+ throw new StopError(`There's no folder here for that project. Run ${chalk.cyan(`storymaps pull ${source}`)} first.`);
45
+ }
46
+ let id;
47
+ try {
48
+ ({ id } = await resolveTarget(source));
49
+ } catch {
50
+ throw new StopError(`File not found: ${source}`);
51
+ }
52
+ const inFolder = ctx?.files.find(f => f.id === id);
53
+ if (inFolder) return inFolder.path;
54
+ const single = resolve('storymap.yml');
55
+ if (existsSync(single)) {
56
+ try {
57
+ if (String(jsyaml.load(await readFile(single, 'utf-8'))?.id) === id) return single;
58
+ } catch { /* not readable, so not this map */ }
59
+ }
60
+ throw new StopError(`There's no local file for that map here. Run ${chalk.cyan(`storymaps pull ${source}`)} to get one.`);
61
+ }
62
+
28
63
  export async function diffCommand(source, target, opts) {
29
- const sourcePath = resolve(source || 'storymap.yml');
64
+ // No files named in a project folder: every map against the server
65
+ if (!source) {
66
+ const ctx = await findProjectFolder({ dir: opts.dir });
67
+ if (ctx?.projectId) {
68
+ await diffProject(ctx, { full: opts.full });
69
+ return;
70
+ }
71
+ }
72
+
73
+ // A link or map ID rather than a file: that map's local file, compared with the server
74
+ let sourcePath = resolve(source || 'storymap.yml');
75
+ if (source && !target && !existsSync(sourcePath)) {
76
+ sourcePath = await localFileFor(source, opts);
77
+ if (!sourcePath) return;
78
+ }
30
79
  const sourceYaml = await readAndNormalise(sourcePath);
31
80
 
32
81
  // Show changes from old to new. Two local files go from the first to the second. Against the
@@ -49,7 +98,12 @@ export async function diffCommand(source, target, opts) {
49
98
  const json = yamlObjToJson(obj);
50
99
  oldYaml = exportToYaml(json);
51
100
  newYaml = sourceYaml;
52
- oldLabel = `remote: ${baseUrl}/${id}`;
101
+ // A map in a project shows its project link
102
+ let project;
103
+ try {
104
+ project = jsyaml.load(await readFile(sourcePath, 'utf-8'))?.project;
105
+ } catch { /* JSON or unreadable: the plain link will do */ }
106
+ oldLabel = `remote: ${project ? `${baseUrl}/p/${project}/${id}` : `${baseUrl}/${id}`}`;
53
107
  newLabel = `local: ${sourcePath}`;
54
108
  }
55
109
 
@@ -67,41 +121,7 @@ export async function diffCommand(source, target, opts) {
67
121
  return;
68
122
  }
69
123
 
70
- // --full: show line-level diff
71
- const CONTEXT = 3;
72
- const lines = [];
73
- let oldLine = 1;
74
- let newLine = 1;
75
- for (const part of changes) {
76
- for (const text of part.value.replace(/\n$/, '').split('\n')) {
77
- if (part.added) {
78
- lines.push({ tag: '+', text, oldNum: null, newNum: newLine++ });
79
- } else if (part.removed) {
80
- lines.push({ tag: '-', text, oldNum: oldLine++, newNum: null });
81
- } else {
82
- lines.push({ tag: ' ', text, oldNum: oldLine++, newNum: newLine++ });
83
- }
84
- }
85
- }
86
-
87
- const width = String(Math.max(oldLine, newLine)).length;
88
- const pad = n => n === null ? ' '.repeat(width) : String(n).padStart(width);
89
-
90
124
  console.log(chalk.bold(`--- ${oldLabel}`));
91
125
  console.log(chalk.bold(`+++ ${newLabel}`));
92
-
93
- let lastPrinted = -1;
94
- for (let i = 0; i < lines.length; i++) {
95
- const nearby = lines.slice(Math.max(0, i - CONTEXT), i + CONTEXT + 1);
96
- if (!nearby.some(l => l.tag !== ' ')) continue;
97
- if (lastPrinted !== -1 && i - lastPrinted > 1) {
98
- console.log(chalk.dim(`${'·'.repeat(width * 2 + 5)}`));
99
- }
100
- const { tag, text, oldNum, newNum } = lines[i];
101
- const num = chalk.dim(`${pad(oldNum)} ${pad(newNum)}`);
102
- if (tag === '+') console.log(`${num} ${chalk.green(`+ ${text}`)}`);
103
- else if (tag === '-') console.log(`${num} ${chalk.red(`- ${text}`)}`);
104
- else console.log(`${num} ${chalk.dim(` ${text}`)}`);
105
- lastPrinted = i;
106
- }
126
+ printFullDiff(changes);
107
127
  }
@@ -6,9 +6,10 @@ import { existsSync } from 'node:fs';
6
6
  import { resolve } from 'node:path';
7
7
  import { confirm } from '@inquirer/prompts';
8
8
  import chalk from 'chalk';
9
- import { postMap } from '../util/api.js';
9
+ import { getMap, postMap } from '../util/api.js';
10
+ import { writeEtag } from '../util/file.js';
10
11
 
11
- function template(site) {
12
+ export function template(site) {
12
13
  const siteLine = site ? `site: ${site}\n` : '';
13
14
  return `\
14
15
  name: My Story Map
@@ -75,8 +76,9 @@ export async function initCommand(opts) {
75
76
  : `https://${site}`;
76
77
 
77
78
  let output = template(site);
79
+ let id = null;
78
80
  try {
79
- const { id } = await postMap(baseUrl, output, 'text/yaml');
81
+ ({ id } = await postMap(baseUrl, output, 'text/yaml'));
80
82
  output = output.replace(/^(name: My Story Map\n)/, `$1id: ${id}\n`);
81
83
  console.log(chalk.green(`Created storymap.yml (map ID: ${id})`));
82
84
  console.log(`${chalk.cyan(`${baseUrl}/${id}`)}\n`);
@@ -86,5 +88,12 @@ export async function initCommand(opts) {
86
88
  }
87
89
 
88
90
  await writeFile(filePath, output, 'utf-8');
91
+ // The file matches the new map, so the first push from it stops if the map changes in the browser first
92
+ if (id) {
93
+ try {
94
+ const { etag } = await getMap(baseUrl, id);
95
+ await writeEtag(id, filePath, etag);
96
+ } catch { /* non-fatal */ }
97
+ }
89
98
  console.log(highlightYaml(output));
90
99
  }