@dittowords/cli 2.0.0 → 2.2.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
@@ -16,9 +16,50 @@ npm install --global @dittowords/cli
16
16
 
17
17
  The installed binary is named `ditto-cli`. You can execute it directly in `node_modules/.bin/ditto-cli` or using [npx](https://www.npmjs.com/package/npx) (with or without installation) like `npx @dittowords/cli`.
18
18
 
19
- The first time you run the CLI, you'll be asked to provide an API key (found at [https://beta.dittowords.com/account/user](https://beta.dittowords.com/account/user) under **API Keys**).
19
+ The first time you run the CLI, you'll be asked to provide an API key (found at [https://beta.dittowords.com/account/user](https://beta.dittowords.com/account/user) under **API Keys**):
20
20
 
21
- Once you've successfully authenticated, you’re ready to start fetching copy! You can set up the CLI in multiple directories by running `ditto-cli` and choosing an initial project to sync from.
21
+ ```
22
+ $ npx @dittowords/cli
23
+
24
+ ┌──────────────────────────────────┐
25
+ │ │
26
+ │ Welcome to the Ditto CLI. │
27
+ │ │
28
+ │ We're glad to have you here. │
29
+ │ │
30
+ └──────────────────────────────────┘
31
+
32
+ What is your API key? > xxx-xxx-xxx
33
+
34
+ Thanks for authenticating.
35
+ We'll save the key to: /Users/{username}/.config/ditto
36
+ ```
37
+
38
+ Once you've successfully authenticated, you'll be asked to configure the CLI with an initial project from your workspace:
39
+
40
+ ```
41
+ Looks like there are no Ditto projects selected for your current directory.
42
+
43
+ ? Choose the project you'd like to sync text from:
44
+ - Ditto Component Library https://beta.dittowords.com/components/all
45
+ - NUX Onboarding Flow https://beta.dittowords.com/doc/609e9981c313f8018d0c346a
46
+ ...
47
+ ```
48
+
49
+ After selecting a project, a configuration file will automatically be created at the path `./ditto/config.yml`, relative to your current working directory. The CLI will attempt to read from this file every time a command is executed. See the [documentation on config.yml](#files) further down in this README for a full reference of how the CLI can be configured.
50
+
51
+ Once you've successfully authenticated and a config file has been created, you’re ready to start fetching copy! You can set up the CLI in multiple directories by running `ditto-cli` and choosing an initial project to sync from.
52
+
53
+ ## API Keys
54
+
55
+ The CLI will not prompt for an API key if a value is provided in the environment variable `DITTO_API_KEY`.
56
+
57
+ If the `DITTO_API_KEY` environment variable is not set, then the CLI will attempt to parse a token from a file at the
58
+ path `~/.config/ditto`. If this file does not exist or does not contain a valid API key, then the CLI will prompt
59
+ for one.
60
+
61
+ We don't recommend editing the file manually; if you need to remove a saved API key and put another one in its place,
62
+ it's better to fully delete the file and then re-run the CLI so that a new key is prompted for.
22
63
 
23
64
  ## Commands
24
65
 
@@ -52,7 +93,7 @@ This folder houses the configuration file (`ditto/config.yml`) used by the CLI a
52
93
 
53
94
  If you run the CLI in a directory that does not contain a `ditto/` folder, the folder and a `config.yml` file will be automatically created.
54
95
 
55
- - #### `config.yml`
96
+ - #### config.yml
56
97
 
57
98
  This is the source of truth for a given directory about how the CLI should fetch and store data from Ditto. It includes information about which Ditto projects the CLI should pull text from and in what format the text should be stored.
58
99
 
@@ -60,16 +101,62 @@ If you run the CLI in a directory that does not contain a `ditto/` folder, the f
60
101
 
61
102
  **Supported properties**
62
103
 
63
- - `projects` (required) - a list of project names and ids to pull text from (see example)
64
- - `variants` (optional) - a `true` or `false` value indicating whether or not variant data should be pulled for the specified projects. Defaults to `false` if not specified (will likely default to `true` in future major releases).
65
- - `format` (optional) - the format the specified projects should be stored in. Acceptable values are `structured` or `flat`. If not specified, the default format containing block and frame data will be used.
104
+ ##### `projects`
105
+
106
+ A list of project names and ids to pull text from. R
107
+
108
+ equired if `components: true` is not specified.
109
+
110
+ **Note**: the `name` property is used for display purposes when referencing a project in the CLI, but does not have to be an
111
+ exact match with the project name in Ditto.
66
112
 
67
- **Example**:
113
+ ```yml
114
+ projects:
115
+ - name: Landing Page Copy
116
+ id: 61b8d26105f8f400e97fdd14
117
+ - name: User Settings
118
+ id: 606cb89ac55041013d552f8b
119
+ ```
120
+
121
+ ##### `components`
122
+
123
+ If included with a value of `true`, data from the component library will be fetched and included in the CLI's output.
124
+
125
+ Required if `projects` is not specified with a valid list of projects.
68
126
 
127
+ ```yml
128
+ components: true
69
129
  ```
130
+
131
+ ##### `variants`
132
+
133
+ If included with a value of `true`, variant data will be pulled for the specified projects and/or the component library.
134
+
135
+ Defaults to `false`.
136
+
137
+ ```yml
138
+ variants: true
139
+ ```
140
+
141
+ ##### `format`
142
+
143
+ The format the specified projects should be stored in. Acceptable values are `structured` or `flat`.
144
+
145
+ If not specified, the default format containing block and frame data will be used.
146
+
147
+ ```yml
148
+ format: flat
149
+ ```
150
+
151
+ **Full Example**
152
+
153
+ ```yml
70
154
  projects:
71
- - name: Ditto Component Library
72
- id: ditto_component_library
155
+ - name: Landing Page Copy
156
+ id: 61b8d26105f8f400e97fdd14
157
+ - name: User Settings
158
+ id: 606cb89ac55041013d552f8b
159
+ components: true
73
160
  variants: true
74
161
  format: flat
75
162
  ```
package/lib/config.js CHANGED
@@ -1,9 +1,10 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
3
  const url = require("url");
4
-
5
4
  const yaml = require("js-yaml");
6
5
 
6
+ const consts = require("./consts");
7
+
7
8
  function createFileIfMissing(filename) {
8
9
  const dir = path.dirname(filename);
9
10
 
@@ -14,7 +15,13 @@ function createFileIfMissing(filename) {
14
15
  }
15
16
  }
16
17
 
17
- function readData(file, defaultData = {}) {
18
+ /**
19
+ * Read data from a file
20
+ * @param {string} file defaults to `PROJECT_CONFIG_FILE` defined in `constants.js`
21
+ * @param {*} defaultData defaults to `{}`
22
+ * @returns
23
+ */
24
+ function readData(file = consts.PROJECT_CONFIG_FILE, defaultData = {}) {
18
25
  createFileIfMissing(file);
19
26
  const fileContents = fs.readFileSync(file, "utf8");
20
27
  return yaml.safeLoad(fileContents) || defaultData;
@@ -50,7 +57,16 @@ function saveToken(file, host, token) {
50
57
  writeData(file, data);
51
58
  }
52
59
 
60
+ function getTokenFromEnv() {
61
+ return process.env.DITTO_API_KEY;
62
+ }
63
+
53
64
  function getToken(file, host) {
65
+ const tokenFromEnv = getTokenFromEnv();
66
+ if (tokenFromEnv) {
67
+ return tokenFromEnv;
68
+ }
69
+
54
70
  const data = readData(file);
55
71
  const hostEntry = data[justTheHost(host)];
56
72
  if (!hostEntry) return undefined;
@@ -72,6 +88,73 @@ function save(file, key, value) {
72
88
  writeData(file, data);
73
89
  }
74
90
 
91
+ const IS_DUPLICATE = /-(\d+$)/;
92
+ function dedupeProjectName(projectNames, projectName) {
93
+ let dedupedName = projectName;
94
+
95
+ if (projectNames.has(dedupedName)) {
96
+ while (projectNames.has(dedupedName)) {
97
+ const [_, numberStr] = dedupedName.match(IS_DUPLICATE) || [];
98
+ if (numberStr && !isNaN(parseInt(numberStr))) {
99
+ dedupedName = `${dedupedName.replace(IS_DUPLICATE, "")}-${
100
+ parseInt(numberStr) + 1
101
+ }`;
102
+ } else {
103
+ dedupedName = `${dedupedName}-1`;
104
+ }
105
+ }
106
+ }
107
+
108
+ return dedupedName;
109
+ }
110
+
111
+ /**
112
+ * Reads from the config file, filters out
113
+ * invalid projects, dedupes those remaining, and returns:
114
+ * - whether or not the data required to `pull` is present
115
+ * - whether or not the component library should be fetched
116
+ * - an array of valid, deduped projects
117
+ * - the `variants` and `format` config options
118
+ */
119
+ function parseSourceInformation() {
120
+ const { projects, components, variants, format } = readData();
121
+
122
+ const projectNames = new Set();
123
+ const validProjects = [];
124
+
125
+ let componentLibraryInProjects = false;
126
+
127
+ (projects || []).forEach((project) => {
128
+ const isValid = project.id && project.name;
129
+ if (!isValid) {
130
+ return;
131
+ }
132
+
133
+ if (project.id === "ditto_component_library") {
134
+ componentLibraryInProjects = true;
135
+ return;
136
+ }
137
+
138
+ project.fileName = dedupeProjectName(projectNames, project.name);
139
+ projectNames.add(project.fileName);
140
+
141
+ validProjects.push(project);
142
+ });
143
+
144
+ const shouldFetchComponentLibrary =
145
+ !!components || componentLibraryInProjects;
146
+
147
+ const hasSourceData = validProjects.length || shouldFetchComponentLibrary;
148
+
149
+ return {
150
+ hasSourceData,
151
+ validProjects,
152
+ shouldFetchComponentLibrary,
153
+ variants,
154
+ format,
155
+ };
156
+ }
157
+
75
158
  module.exports = {
76
159
  createFileIfMissing,
77
160
  readData,
@@ -80,5 +163,7 @@ module.exports = {
80
163
  saveToken,
81
164
  deleteToken,
82
165
  getToken,
166
+ getTokenFromEnv,
83
167
  save,
168
+ parseSourceInformation,
84
169
  };
package/lib/init/init.js CHANGED
@@ -2,13 +2,16 @@
2
2
  // expected to be run once per project.
3
3
  const boxen = require("boxen");
4
4
  const chalk = require("chalk");
5
- const getSelectedProjects = require("../utils/getSelectedProjects");
6
5
  const projectsToText = require("../utils/projectsToText");
7
6
 
8
- const { needsProjects, collectAndSaveProject } = require("./project");
7
+ const { needsSource, collectAndSaveProject } = require("./project");
9
8
  const { needsToken, collectAndSaveToken } = require("./token");
10
9
 
11
- const needsInit = () => needsToken() || needsProjects();
10
+ const config = require("../config");
11
+ const output = require("../output");
12
+ const sourcesToText = require("../utils/sourcesToText");
13
+
14
+ const needsInit = () => needsToken() || needsSource();
12
15
 
13
16
  function welcome() {
14
17
  const msg = chalk.white(`${chalk.bold(
@@ -22,19 +25,24 @@ We're glad to have you here.`);
22
25
 
23
26
  async function init() {
24
27
  welcome();
28
+
25
29
  if (needsToken()) {
26
30
  await collectAndSaveToken();
27
31
  }
28
- const selectedProjects = getSelectedProjects();
29
- if (selectedProjects.length) {
30
- console.log(
31
- `You're currently set up to sync text from the following projects: ${projectsToText(
32
- selectedProjects
33
- )}\n`
34
- );
35
- } else {
32
+
33
+ const { hasSourceData, validProjects, shouldFetchComponentLibrary } =
34
+ config.parseSourceInformation();
35
+
36
+ if (!hasSourceData) {
36
37
  await collectAndSaveProject(true);
38
+ return;
37
39
  }
40
+
41
+ const message =
42
+ "You're currently set up to sync text from " +
43
+ sourcesToText(validProjects, shouldFetchComponentLibrary);
44
+
45
+ console.log(message);
38
46
  }
39
47
 
40
48
  module.exports = { needsInit, init };
@@ -15,14 +15,21 @@ function quit(exitCode = 2) {
15
15
  }
16
16
 
17
17
  function saveProject(file, name, id) {
18
+ // old functionality included "ditto_component_library" in the `projects`
19
+ // array, but we want to always treat the component library as a separate
20
+ // entity and use the new notation of a top-level `components` key
21
+ if (id === "ditto_component_library") {
22
+ config.writeData(file, { components: true });
23
+ return;
24
+ }
25
+
18
26
  const projects = [...getSelectedProjects(), { name, id }];
19
27
 
20
28
  config.writeData(file, { projects });
21
29
  }
22
30
 
23
- function needsProjects(configFile) {
24
- const projects = getSelectedProjects(configFile);
25
- return !(projects && projects.length);
31
+ function needsSource() {
32
+ return !config.parseSourceInformation().hasSourceData;
26
33
  }
27
34
 
28
35
  async function askForAnotherToken() {
@@ -60,7 +67,7 @@ async function collectProject(token, initialize) {
60
67
  const path = process.cwd();
61
68
  if (initialize) {
62
69
  console.log(
63
- `Looks like are no Ditto projects selected for your current directory: ${output.info(
70
+ `Looks like there are no Ditto projects selected for your current directory: ${output.info(
64
71
  path
65
72
  )}.`
66
73
  );
@@ -113,4 +120,4 @@ async function collectAndSaveProject(initialize = false) {
113
120
  }
114
121
  }
115
122
 
116
- module.exports = { needsProjects, collectAndSaveProject };
123
+ module.exports = { needsSource, collectAndSaveProject };
package/lib/init/token.js CHANGED
@@ -10,6 +10,10 @@ const output = require("../output");
10
10
  const config = require("../config");
11
11
 
12
12
  function needsToken(configFile, host = consts.API_HOST) {
13
+ if (config.getTokenFromEnv()) {
14
+ return false;
15
+ }
16
+
13
17
  const file = configFile || consts.CONFIG_FILE;
14
18
  if (!fs.existsSync(file)) return true;
15
19
  const configData = config.readData(file);
package/lib/pull.js CHANGED
@@ -9,6 +9,7 @@ const consts = require("./consts");
9
9
  const output = require("./output");
10
10
  const { collectAndSaveToken } = require("./init/token");
11
11
  const projectsToText = require("./utils/projectsToText");
12
+ const sourcesToText = require("./utils/sourcesToText");
12
13
 
13
14
  const NON_DEFAULT_FORMATS = ["flat", "structured"];
14
15
 
@@ -30,54 +31,6 @@ async function askForAnotherToken() {
30
31
  await collectAndSaveToken(message);
31
32
  }
32
33
 
33
- function cleanProjectName(projectName) {
34
- return (
35
- projectName
36
- .replace(/\s/g, "-")
37
- // replace double underscore since this is what we use
38
- // to separate project names and API IDs
39
- .replace(/__/g, "_")
40
- .toLowerCase()
41
- .trim()
42
- );
43
- }
44
-
45
- /**
46
- * Return the passed array of projects with `project.name` modified
47
- * to be `${project.name}-${duplicate_number}` for each project
48
- * that has the same name as another project in the original array.
49
- */
50
- const IS_DUPLICATE = /-(\d+$)/;
51
- function getProjectsWithDedupedNames(projects) {
52
- const projectsWithDedupedNames = [];
53
- const projectNames = {};
54
-
55
- projects.forEach(({ id, name }) => {
56
- let dedupedName = name;
57
-
58
- if (projectNames[dedupedName]) {
59
- while (projectNames[dedupedName]) {
60
- const [_, numberStr] = dedupedName.match(IS_DUPLICATE) || [];
61
- if (numberStr && !isNaN(parseInt(numberStr))) {
62
- dedupedName = `${dedupedName.replace(IS_DUPLICATE, "")}-${
63
- parseInt(numberStr) + 1
64
- }`;
65
- } else {
66
- dedupedName = `${dedupedName}-1`;
67
- }
68
- }
69
- }
70
-
71
- projectNames[dedupedName] = true;
72
- projectsWithDedupedNames.push({
73
- id,
74
- name: cleanProjectName(dedupedName),
75
- });
76
- });
77
-
78
- return projectsWithDedupedNames;
79
- }
80
-
81
34
  /**
82
35
  * For a given variant:
83
36
  * - if format is unspecified, fetch data for all projects from `/projects` and
@@ -93,7 +46,7 @@ async function downloadAndSaveVariant(variantApiId, projects, format, token) {
93
46
 
94
47
  if (NON_DEFAULT_FORMATS.includes(format)) {
95
48
  const savedMessages = await Promise.all(
96
- projects.map(async ({ id, name }) => {
49
+ projects.map(async ({ id, fileName }) => {
97
50
  const { data } = await api.get(`/projects/${id}`, {
98
51
  params,
99
52
  headers: { Authorization: `token ${token}` },
@@ -103,7 +56,7 @@ async function downloadAndSaveVariant(variantApiId, projects, format, token) {
103
56
  return "";
104
57
  }
105
58
 
106
- const filename = name + ("__" + (variantApiId || "base")) + ".json";
59
+ const filename = fileName + ("__" + (variantApiId || "base")) + ".json";
107
60
  const filepath = path.join(consts.TEXT_DIR, filename);
108
61
 
109
62
  const dataString = JSON.stringify(data, null, 2);
@@ -162,13 +115,13 @@ async function downloadAndSaveBase(projects, format, token) {
162
115
 
163
116
  if (NON_DEFAULT_FORMATS.includes(format)) {
164
117
  const savedMessages = await Promise.all(
165
- projects.map(async ({ id, name }) => {
118
+ projects.map(async ({ id, fileName }) => {
166
119
  const { data } = await api.get(`/projects/${id}`, {
167
120
  params,
168
121
  headers: { Authorization: `token ${token}` },
169
122
  });
170
123
 
171
- const filename = `${name}.json`;
124
+ const filename = `${fileName}.json`;
172
125
  const filepath = path.join(consts.TEXT_DIR, filename);
173
126
 
174
127
  const dataString = JSON.stringify(data, null, 2);
@@ -195,7 +148,7 @@ async function downloadAndSaveBase(projects, format, token) {
195
148
  }
196
149
 
197
150
  function getSavedMessage(file) {
198
- return `Successfully saved to ${output.info(file)}.\n`;
151
+ return `Successfully saved to ${output.info(file)}\n`;
199
152
  }
200
153
 
201
154
  function cleanOutputFiles() {
@@ -235,7 +188,7 @@ function generateJsDriver(projects, variants, format) {
235
188
  .filter((fileName) => /\.json$/.test(fileName));
236
189
 
237
190
  const projectIdsByName = projects.reduce(
238
- (agg, project) => ({ ...agg, [project.name]: project.id }),
191
+ (agg, project) => ({ ...agg, [project.fileName]: project.id }),
239
192
  {}
240
193
  );
241
194
 
@@ -305,28 +258,40 @@ function generateJsDriver(projects, variants, format) {
305
258
  const filePath = path.resolve(consts.TEXT_DIR, "index.js");
306
259
  fs.writeFileSync(filePath, dataString, { encoding: "utf8" });
307
260
 
308
- return `Generated .js SDK driver at ${output.info(filePath)}..`;
261
+ return `Generated .js SDK driver at ${output.info(filePath)}`;
309
262
  }
310
263
 
311
- async function downloadAndSave(projectConfig, token) {
312
- const { projects, variants, format } = projectConfig;
313
- const projectsDeduped = getProjectsWithDedupedNames(projects);
264
+ async function downloadAndSave(sourceInformation, token) {
265
+ const { validProjects, variants, format, shouldFetchComponentLibrary } =
266
+ sourceInformation;
314
267
 
315
- let msg = `\nFetching the latest text from your selected projects: ${projectsToText(
316
- projects
268
+ let msg = `\nFetching the latest text from ${sourcesToText(
269
+ validProjects,
270
+ shouldFetchComponentLibrary
317
271
  )}\n`;
318
272
 
319
273
  const spinner = ora(msg);
320
274
  spinner.start();
321
275
 
276
+ // We'll need to move away from this solution if at some
277
+ // point down the road we stop allowing the component
278
+ // library to be returned from the /projects endpoint
279
+ if (shouldFetchComponentLibrary) {
280
+ validProjects.push({
281
+ id: "ditto_component_library",
282
+ name: "Ditto Component Library",
283
+ fileName: "ditto-component-library",
284
+ });
285
+ }
286
+
322
287
  try {
323
288
  msg += cleanOutputFiles();
324
289
 
325
290
  msg += variants
326
- ? await downloadAndSaveVariants(projectsDeduped, format, token)
327
- : await downloadAndSaveBase(projectsDeduped, format, token);
291
+ ? await downloadAndSaveVariants(validProjects, format, token)
292
+ : await downloadAndSaveBase(validProjects, format, token);
328
293
 
329
- msg += generateJsDriver(projectsDeduped, variants, format);
294
+ msg += generateJsDriver(validProjects, variants, format);
330
295
 
331
296
  msg += `\n${output.success("Done")}!`;
332
297
 
@@ -371,15 +336,15 @@ async function downloadAndSave(projectConfig, token) {
371
336
 
372
337
  function pull() {
373
338
  const token = config.getToken(consts.CONFIG_FILE, consts.API_HOST);
374
- const pConfig = config.readData(consts.PROJECT_CONFIG_FILE);
375
- return downloadAndSave(pConfig, token);
339
+ const sourceInformation = config.parseSourceInformation();
340
+
341
+ return downloadAndSave(sourceInformation, token);
376
342
  }
377
343
 
378
344
  module.exports = {
379
345
  pull,
380
346
  _testing: {
381
347
  cleanOutputFiles,
382
- getProjectsWithDedupedNames,
383
348
  downloadAndSaveVariant,
384
349
  downloadAndSaveVariants,
385
350
  downloadAndSaveBase,
@@ -2,7 +2,7 @@ const output = require("../output");
2
2
 
3
3
  function projectsToText(projects) {
4
4
  return (
5
- projects.reduce(
5
+ (projects || []).reduce(
6
6
  (outputString, { name, id }) =>
7
7
  outputString +
8
8
  ("\n" +
@@ -0,0 +1,24 @@
1
+ const output = require("../output");
2
+ const projectsToText = require("./projectsToText");
3
+
4
+ function sourcesToText(projects, componentLibrary) {
5
+ let message = "";
6
+
7
+ if (componentLibrary) {
8
+ message += `the ${output.info("Ditto Component Library")}`;
9
+
10
+ if ((projects || []).length) {
11
+ message += " and ";
12
+ } else {
13
+ message += "..";
14
+ }
15
+ }
16
+
17
+ if ((projects || []).length) {
18
+ message += `the following projects: ${projectsToText(projects)}\n`;
19
+ }
20
+
21
+ return message;
22
+ }
23
+
24
+ module.exports = sourcesToText;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dittowords/cli",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Command Line Interface for Ditto (dittowords.com).",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {