storymaps 1.0.0 → 1.1.0

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
@@ -143,10 +143,24 @@ storymaps validate storymap.yml
143
143
  ### `convert`
144
144
 
145
145
  ```bash
146
- storymaps convert storymap.yml --to json
146
+ storymaps convert storymap.yml --to json
147
147
  storymaps convert storymap.json --to yaml --out storymap.yml
148
148
  ```
149
149
 
150
+ ### `import`
151
+
152
+ Import tasks from an external project tracker into a storymap. Currently supports Phabricator.
153
+
154
+ ```bash
155
+ storymaps import --from phabricator --tag my-project
156
+ storymaps import --from phab --tag my-project --site localhost
157
+ storymaps import --from phab --tag my-project --status open,in-progress
158
+ ```
159
+
160
+ Epics (tasks tagged with `Epic` in Phabricator) become steps with their subtasks as cards. Standalone tasks are grouped into backlog columns. The map is registered on the server automatically.
161
+
162
+ On first run you'll be prompted for your Phabricator instance URL and Conduit API token. Credentials are saved to `~/.storymaps/credentials.json`.
163
+
150
164
  ## YAML Format
151
165
 
152
166
  ```yaml
package/bin/storymaps.js CHANGED
@@ -13,6 +13,7 @@ import { openCommand } from '../lib/commands/open.js';
13
13
  import { lockCommand } from '../lib/commands/lock.js';
14
14
  import { unlockCommand } from '../lib/commands/unlock.js';
15
15
  import { statusCommand } from '../lib/commands/status.js';
16
+ import { importCommand } from '../lib/commands/import.js';
16
17
 
17
18
  const program = new Command();
18
19
 
@@ -97,6 +98,16 @@ program
97
98
  .option('-f, --file <path>', 'path to storymap file', 'storymap.yml')
98
99
  .action(statusCommand);
99
100
 
101
+ program
102
+ .command('import')
103
+ .description('Import tasks from an external tool into a storymap')
104
+ .requiredOption('--from <source>', 'source platform (phabricator)')
105
+ .requiredOption('--tag <slug>', 'project tag/slug to import from')
106
+ .option('--status <statuses>', 'comma-separated statuses to import', 'open,in-progress,stalled,resolved')
107
+ .option('--output <path>', 'output file path', 'storymap.yml')
108
+ .option('-s, --site <site>', 'target site (e.g. localhost, storymaps.io)')
109
+ .action(importCommand);
110
+
100
111
  program.parseAsync().catch(err => {
101
112
  const message = err.name === 'ExitPromptError' ? 'Cancelled.'
102
113
  : err.message || String(err);
@@ -0,0 +1,249 @@
1
+ // Storymaps CLI — AGPL-3.0 — see LICENCE for details
2
+ // storymaps import — import tasks from an external tool into a storymap
3
+
4
+ import { existsSync } from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+ import chalk from 'chalk';
7
+ import { input, password, confirm } from '@inquirer/prompts';
8
+ import { readCredentials, writeCredentials, writeStorymap } from '../util/file.js';
9
+ import { dumpYaml } from '../core/yaml.js';
10
+ import { postMap } from '../util/api.js';
11
+ import {
12
+ findProject,
13
+ findEpicTagPhid,
14
+ fetchTasks,
15
+ fetchTasksByPhids,
16
+ fetchSubtaskEdges,
17
+ CLI_TO_PHAB_STATUS,
18
+ PHAB_TO_STORYMAP_STATUS,
19
+ } from '../util/phabricator.js';
20
+
21
+ const CARDS_PER_COLUMN = 10;
22
+
23
+ async function promptCredentials(defaultUrl) {
24
+ const url = await input({
25
+ message: 'Phabricator instance URL:',
26
+ default: defaultUrl,
27
+ theme: { prefix: '>' },
28
+ validate: v => v.startsWith('http') || 'Must be a full URL (https://...)',
29
+ });
30
+ console.log();
31
+ console.log(chalk.dim(' To get your API token:'));
32
+ console.log(chalk.dim(' 1. Click your profile picture in Phabricator'));
33
+ console.log(chalk.dim(' 2. Go to Settings'));
34
+ console.log(chalk.dim(' 3. Click "Conduit API Tokens"'));
35
+ console.log(chalk.dim(' 4. Click "Generate Token"'));
36
+ console.log();
37
+ const token = await password({
38
+ message: 'API token:',
39
+ mask: '*',
40
+ theme: { prefix: '>' },
41
+ });
42
+ return { url: url.replace(/\/+$/, ''), token };
43
+ }
44
+
45
+ async function getCredentials() {
46
+ const saved = await readCredentials('phabricator');
47
+ if (saved?.url && saved?.token) {
48
+ console.log(chalk.dim(`Using saved Phabricator credentials (${saved.url})`));
49
+ return saved;
50
+ }
51
+ const creds = await promptCredentials(saved?.url);
52
+ await writeCredentials('phabricator', creds);
53
+ console.log(chalk.dim('Credentials saved to ~/.storymaps/credentials.json'));
54
+ return creds;
55
+ }
56
+
57
+ function buildYamlObj(projectName, epics, standalones, baseUrl, site) {
58
+ const steps = [];
59
+ const importedStories = {};
60
+ const backlogStories = {};
61
+
62
+ // Epic steps + stories
63
+ for (const epic of epics) {
64
+ const step = { name: epic.name };
65
+ if (epic.description) step.body = epic.description;
66
+ step.url = `${baseUrl}/T${epic.id}`;
67
+ if (epic.points != null) step.points = epic.points;
68
+ steps.push(step);
69
+ importedStories[epic.name] = epic.children.map(task => taskToCard(task, baseUrl));
70
+ }
71
+
72
+ // Backlog columns
73
+ const numColumns = Math.max(1, Math.ceil(standalones.length / CARDS_PER_COLUMN));
74
+ for (let col = 0; col < numColumns; col++) {
75
+ const stepName = String(col + 1);
76
+ steps.push(stepName);
77
+ const chunk = standalones.slice(col * CARDS_PER_COLUMN, (col + 1) * CARDS_PER_COLUMN);
78
+ if (chunk.length) {
79
+ backlogStories[stepName] = chunk.map(task => taskToCard(task, baseUrl));
80
+ }
81
+ }
82
+
83
+ const slices = [];
84
+ if (Object.keys(importedStories).length) {
85
+ slices.push({ name: 'Imported', stories: importedStories });
86
+ }
87
+ if (Object.keys(backlogStories).length) {
88
+ slices.push({ name: 'Backlog', stories: backlogStories });
89
+ }
90
+
91
+ const obj = { name: projectName, id: undefined, site, steps, slices };
92
+ return obj;
93
+ }
94
+
95
+ function taskToCard(task, baseUrl) {
96
+ const card = { name: task.name };
97
+ const status = PHAB_TO_STORYMAP_STATUS[task.status];
98
+ if (status) card.status = status;
99
+ if (task.description) card.body = task.description;
100
+ if (task.points != null) card.points = task.points;
101
+ card.url = `${baseUrl}/T${task.id}`;
102
+ return card;
103
+ }
104
+
105
+ export async function importCommand(opts) {
106
+ const source = opts.from === 'phab' ? 'phabricator' : opts.from;
107
+ if (source !== 'phabricator') {
108
+ console.error(chalk.red(`Unsupported source: ${source}. Supported: phabricator (or phab)`));
109
+ process.exit(1);
110
+ }
111
+
112
+ const tag = opts.tag;
113
+ if (!tag) {
114
+ console.error(chalk.red('--tag is required'));
115
+ process.exit(1);
116
+ }
117
+
118
+ const outputPath = resolve(opts.output || 'storymap.yml');
119
+ const statusList = (opts.status || 'open,in-progress,stalled,resolved').split(',').map(s => s.trim());
120
+
121
+ // Validate status names
122
+ for (const s of statusList) {
123
+ if (!CLI_TO_PHAB_STATUS[s]) {
124
+ console.error(chalk.red(`Unknown status: ${s}. Valid: ${Object.keys(CLI_TO_PHAB_STATUS).join(', ')}`));
125
+ process.exit(1);
126
+ }
127
+ }
128
+
129
+ const phabStatuses = statusList.map(s => CLI_TO_PHAB_STATUS[s]);
130
+
131
+ // Credentials
132
+ let { url: baseUrl, token } = await getCredentials();
133
+
134
+ // Resolve project (retry on auth error)
135
+ console.log(chalk.dim(`Looking up project "${tag}"...`));
136
+ let project;
137
+ try {
138
+ project = await findProject(baseUrl, token, tag);
139
+ } catch (err) {
140
+ if (err.message.includes('ERR-INVALID-AUTH') || err.message.includes('ERR-CONDUIT-CORE')) {
141
+ console.error(chalk.red(err.message));
142
+ console.log();
143
+ const creds = await promptCredentials(baseUrl);
144
+ baseUrl = creds.url;
145
+ token = creds.token;
146
+ try {
147
+ project = await findProject(baseUrl, token, tag);
148
+ await writeCredentials('phabricator', creds);
149
+ console.log(chalk.dim('Credentials saved to ~/.storymaps/credentials.json'));
150
+ } catch (retryErr) {
151
+ console.error(chalk.red(retryErr.message));
152
+ process.exit(1);
153
+ }
154
+ } else {
155
+ console.error(chalk.red(err.message));
156
+ process.exit(1);
157
+ }
158
+ }
159
+ console.log(`Project: ${chalk.cyan(project.name)}`);
160
+
161
+ // Fetch tasks and resolve epic tag
162
+ console.log(chalk.dim('Fetching tasks...'));
163
+ let tasks, epicPhid;
164
+ try {
165
+ [tasks, epicPhid] = await Promise.all([
166
+ fetchTasks(baseUrl, token, project.phid, phabStatuses),
167
+ findEpicTagPhid(baseUrl, token),
168
+ ]);
169
+ } catch (err) {
170
+ console.error(chalk.red(`Failed to fetch tasks: ${err.message}`));
171
+ process.exit(1);
172
+ }
173
+
174
+ if (!tasks.length) {
175
+ console.log(chalk.yellow('No tasks found.'));
176
+ return;
177
+ }
178
+
179
+ // Identify epics by the Epic project tag
180
+ const epicTasks = epicPhid ? tasks.filter(t => t.projectPhids.includes(epicPhid)) : [];
181
+ const epicPhids = new Set(epicTasks.map(t => t.phid));
182
+
183
+ // Fetch subtasks for epics
184
+ let edges = [];
185
+ if (epicTasks.length) {
186
+ console.log(chalk.dim('Fetching subtasks...'));
187
+ try {
188
+ edges = await fetchSubtaskEdges(baseUrl, token, epicTasks.map(t => t.phid));
189
+ } catch (err) {
190
+ console.error(chalk.red(`Failed to fetch subtasks: ${err.message}`));
191
+ process.exit(1);
192
+ }
193
+ }
194
+
195
+ // Build epic → children map, fetching any subtasks not in the project
196
+ const taskByPhid = new Map(tasks.map(t => [t.phid, t]));
197
+ const childPhids = new Set(edges.map(e => e.destinationPHID));
198
+ const missingPhids = [...childPhids].filter(phid => !taskByPhid.has(phid));
199
+ if (missingPhids.length) {
200
+ const missing = await fetchTasksByPhids(baseUrl, token, missingPhids);
201
+ for (const t of missing) taskByPhid.set(t.phid, t);
202
+ }
203
+
204
+ const epics = epicTasks.map(epic => {
205
+ const childTaskPhids = edges
206
+ .filter(e => e.sourcePHID === epic.phid)
207
+ .map(e => e.destinationPHID);
208
+ const children = childTaskPhids.map(phid => taskByPhid.get(phid)).filter(Boolean);
209
+ return { ...epic, children };
210
+ });
211
+
212
+ // Standalone = not an epic, not a child of an epic
213
+ const standalones = tasks.filter(t => !epicPhids.has(t.phid) && !childPhids.has(t.phid));
214
+
215
+ const subtaskCount = epics.reduce((sum, e) => sum + e.children.length, 0);
216
+ console.log(` ${epics.length} epics, ${subtaskCount} subtasks`);
217
+ console.log(` ${standalones.length} standalone tasks`);
218
+
219
+ // Build YAML and register on storymaps server
220
+ const site = opts.site || 'storymaps.io';
221
+ const siteBaseUrl = site === 'localhost' ? 'http://localhost:3000' : `https://${site}`;
222
+ const yamlObj = buildYamlObj(project.name, epics, standalones, baseUrl, site);
223
+ let yamlString = dumpYaml(yamlObj);
224
+ try {
225
+ const { id } = await postMap(siteBaseUrl, yamlString, 'text/yaml');
226
+ yamlObj.id = id;
227
+ yamlString = dumpYaml(yamlObj);
228
+ console.log(chalk.dim(` Registered on ${site} (id: ${id})`));
229
+ } catch {
230
+ console.log(chalk.dim(` Could not reach ${site} - run "storymaps push" later to register`));
231
+ }
232
+
233
+ // Write
234
+ if (existsSync(outputPath)) {
235
+ const overwrite = await confirm({
236
+ message: `${outputPath} already exists. Overwrite?`,
237
+ default: false,
238
+ theme: { prefix: '>' },
239
+ });
240
+ if (!overwrite) {
241
+ console.log('Aborted.');
242
+ return;
243
+ }
244
+ }
245
+
246
+ await writeStorymap(outputPath, yamlString);
247
+ console.log(chalk.green(`Wrote ${outputPath}`));
248
+ console.log(chalk.dim(` ${tasks.length} tasks, ${epics.length} epics, ${standalones.length} standalone`));
249
+ }
@@ -12,7 +12,7 @@ export async function openCommand(target, opts) {
12
12
  try {
13
13
  ({ id, baseUrl } = await resolveTarget(target, filePath));
14
14
  } catch {
15
- console.error(chalk.red('No map ID found. Run "storymaps new" first to reserve an ID, or pass one directly: storymaps open <id>'));
15
+ console.error(chalk.red('No map ID found. Run "storymaps push" to create it on the server, or pass one directly: storymaps open <id>'));
16
16
  process.exitCode = 1;
17
17
  return;
18
18
  }
package/lib/core/yaml.js CHANGED
@@ -174,8 +174,15 @@ export function jsonToYamlObj(data) {
174
174
  if (stepTypes[i] === 'spacer') return 'spacer';
175
175
  if (stepTypes[i] === 'partial') return { partial: partialSlugMap[step.partialMapId] };
176
176
  const name = resolvedNames[i];
177
- if (step.color && step.color !== DEFAULT_STEP_COLOR) return { name, color: step.color };
178
- return name;
177
+ const hasExtra = (step.color && step.color !== DEFAULT_STEP_COLOR)
178
+ || step.url || step.body || step.points != null;
179
+ if (!hasExtra) return name;
180
+ const obj = { name };
181
+ if (step.color && step.color !== DEFAULT_STEP_COLOR) obj.color = step.color;
182
+ if (step.body) obj.body = step.body;
183
+ if (step.url) obj.url = step.url;
184
+ if (step.points != null) obj.points = step.points;
185
+ return obj;
179
186
  });
180
187
 
181
188
  // Legend
@@ -321,7 +328,13 @@ export function yamlObjToJson(obj) {
321
328
  const displayName = rawName.replace(/:\[\d+\]\s*/, ': ').replace(/\[\d+\]$/, '');
322
329
  const color = typeof step === 'string' ? DEFAULT_STEP_COLOR : (step.color || DEFAULT_STEP_COLOR);
323
330
  stepNameToIndex[rawName] = jsonSteps.length;
324
- jsonSteps.push({ name: displayName, color });
331
+ const s = { name: displayName, color };
332
+ if (typeof step === 'object' && step !== null) {
333
+ if (step.url) s.url = step.url;
334
+ if (step.body) s.body = step.body;
335
+ if (step.points != null) s.points = step.points;
336
+ }
337
+ jsonSteps.push(s);
325
338
  });
326
339
 
327
340
  const n = jsonSteps.length;
package/lib/util/file.js CHANGED
@@ -7,7 +7,9 @@ import { dirname, basename, join, extname } from 'node:path';
7
7
  import jsyaml from 'js-yaml';
8
8
  import { exportToYaml } from '../core/yaml.js';
9
9
 
10
- const CACHE_DIR = join(homedir(), '.storymaps', 'caches');
10
+ const STORYMAPS_DIR = join(homedir(), '.storymaps');
11
+ const CACHE_DIR = join(STORYMAPS_DIR, 'caches');
12
+ const CREDENTIALS_PATH = join(STORYMAPS_DIR, 'credentials.json');
11
13
 
12
14
  function cachePathFor(mapId) {
13
15
  return join(CACHE_DIR, `${mapId}.json`);
@@ -31,6 +33,25 @@ export async function writeCache(mapId, updates) {
31
33
  await writeFile(cachePathFor(mapId), JSON.stringify(cache), 'utf-8');
32
34
  }
33
35
 
36
+ export async function readCredentials(platform) {
37
+ try {
38
+ const all = JSON.parse(await readFile(CREDENTIALS_PATH, 'utf-8'));
39
+ return all[platform] || null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export async function writeCredentials(platform, credentials) {
46
+ await mkdir(STORYMAPS_DIR, { recursive: true });
47
+ let all = {};
48
+ try {
49
+ all = JSON.parse(await readFile(CREDENTIALS_PATH, 'utf-8'));
50
+ } catch { /* first write */ }
51
+ all[platform] = credentials;
52
+ await writeFile(CREDENTIALS_PATH, JSON.stringify(all, null, 2) + '\n', 'utf-8');
53
+ }
54
+
34
55
  export function detectFormat(filePath) {
35
56
  const ext = extname(filePath).toLowerCase();
36
57
  if (ext === '.json') return 'json';
@@ -0,0 +1,155 @@
1
+ // Storymaps CLI — AGPL-3.0 — see LICENCE for details
2
+ // Phabricator Conduit API client
3
+
4
+ // CLI status name → Phabricator Conduit status value
5
+ export const CLI_TO_PHAB_STATUS = {
6
+ 'open': 'open',
7
+ 'in-progress': 'progress',
8
+ 'stalled': 'stalled',
9
+ 'resolved': 'resolved',
10
+ };
11
+
12
+ // Phabricator Conduit status value → storymap YAML status
13
+ export const PHAB_TO_STORYMAP_STATUS = {
14
+ 'open': 'planned',
15
+ 'progress': 'in-progress',
16
+ 'stalled': 'blocked',
17
+ 'resolved': 'done',
18
+ };
19
+
20
+ async function conduit(baseUrl, method, token, params = {}) {
21
+ const url = `${baseUrl.replace(/\/+$/, '')}/api/${method}`;
22
+ const args = ['curl', '-s', '-X', 'POST', url, '-d', `api.token=${token}`];
23
+ for (const [key, value] of Object.entries(params)) {
24
+ args.push('-d', `${key}=${value}`);
25
+ }
26
+ const { execFile } = await import('node:child_process');
27
+ const { promisify } = await import('node:util');
28
+ const exec = promisify(execFile);
29
+ const { stdout } = await exec(args[0], args.slice(1));
30
+ const json = JSON.parse(stdout);
31
+ if (json.error_code) {
32
+ throw new Error(`Conduit ${method}: [${json.error_code}] ${json.error_info}`);
33
+ }
34
+ return json.result;
35
+ }
36
+
37
+ /**
38
+ * Resolve a project slug to its PHID and name.
39
+ */
40
+ export async function findProject(baseUrl, token, slug) {
41
+ const result = await conduit(baseUrl, 'project.search', token, {
42
+ 'constraints[slugs][0]': slug,
43
+ });
44
+ const project = result.data?.[0];
45
+ if (!project) {
46
+ throw new Error(`Project not found: ${slug}`);
47
+ }
48
+ return { phid: project.phid, name: project.fields.name };
49
+ }
50
+
51
+ /**
52
+ * Find the PHID of the "Epic" project tag. Returns null if not found.
53
+ */
54
+ export async function findEpicTagPhid(baseUrl, token) {
55
+ const result = await conduit(baseUrl, 'project.search', token, {
56
+ 'constraints[slugs][0]': 'epic',
57
+ });
58
+ return result.data?.[0]?.phid || null;
59
+ }
60
+
61
+ /**
62
+ * Fetch all tasks in a project matching the given statuses.
63
+ * Handles cursor-based pagination. Calls onPage(tasks, total) per page.
64
+ */
65
+ export async function fetchTasks(baseUrl, token, projectPhid, statuses, { onPage } = {}) {
66
+ const all = [];
67
+ let after = null;
68
+ for (;;) {
69
+ const params = {
70
+ 'constraints[projects][0]': projectPhid,
71
+ 'attachments[projects]': '1',
72
+ 'order': 'newest',
73
+ };
74
+ statuses.forEach((s, i) => {
75
+ params[`constraints[statuses][${i}]`] = s;
76
+ });
77
+ if (after) params['after'] = after;
78
+
79
+ const result = await conduit(baseUrl, 'maniphest.search', token, params);
80
+ const tasks = (result.data || []).map(t => ({
81
+ id: t.id,
82
+ phid: t.phid,
83
+ name: t.fields.name,
84
+ description: t.fields.description?.raw || '',
85
+ status: t.fields.status?.value || 'open',
86
+ points: t.fields.points != null ? Number(t.fields.points) : null,
87
+ projectPhids: t.attachments?.projects?.projectPHIDs || [],
88
+ }));
89
+ all.push(...tasks);
90
+ if (onPage) onPage(tasks, all.length);
91
+
92
+ after = result.cursor?.after;
93
+ if (!after) break;
94
+ }
95
+ return all;
96
+ }
97
+
98
+ /**
99
+ * Fetch tasks by their PHIDs. Returns the same shape as fetchTasks.
100
+ */
101
+ export async function fetchTasksByPhids(baseUrl, token, phids) {
102
+ const BATCH = 100;
103
+ const all = [];
104
+ for (let i = 0; i < phids.length; i += BATCH) {
105
+ const batch = phids.slice(i, i + BATCH);
106
+ const params = {};
107
+ batch.forEach((phid, j) => {
108
+ params[`constraints[phids][${j}]`] = phid;
109
+ });
110
+ const result = await conduit(baseUrl, 'maniphest.search', token, params);
111
+ const tasks = (result.data || []).map(t => ({
112
+ id: t.id,
113
+ phid: t.phid,
114
+ name: t.fields.name,
115
+ description: t.fields.description?.raw || '',
116
+ status: t.fields.status?.value || 'open',
117
+ points: t.fields.points != null ? Number(t.fields.points) : null,
118
+ projectPhids: [],
119
+ }));
120
+ all.push(...tasks);
121
+ }
122
+ return all;
123
+ }
124
+
125
+ /**
126
+ * Fetch subtask edges for a set of task PHIDs.
127
+ * Returns [{ sourcePHID, destinationPHID }] where source is parent, destination is child.
128
+ * Handles pagination and batches in chunks of 100.
129
+ */
130
+ export async function fetchSubtaskEdges(baseUrl, token, taskPhids) {
131
+ const BATCH = 100;
132
+ const all = [];
133
+ for (let i = 0; i < taskPhids.length; i += BATCH) {
134
+ const batch = taskPhids.slice(i, i + BATCH);
135
+ let after = null;
136
+ for (;;) {
137
+ const params = { 'types[0]': 'task.subtask' };
138
+ batch.forEach((phid, j) => {
139
+ params[`sourcePHIDs[${j}]`] = phid;
140
+ });
141
+ if (after) params['after'] = after;
142
+
143
+ const result = await conduit(baseUrl, 'edge.search', token, params);
144
+ const edges = (result.data || []).map(e => ({
145
+ sourcePHID: e.sourcePHID,
146
+ destinationPHID: e.destinationPHID,
147
+ }));
148
+ all.push(...edges);
149
+
150
+ after = result.cursor?.after;
151
+ if (!after) break;
152
+ }
153
+ }
154
+ return all;
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storymaps",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "CLI tool for storymaps.io - import, export, validate, and manage user story maps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {