@dittowords/cli 1.1.1-beta.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/.eslintrc.js +19 -0
- package/README.md +67 -0
- package/babel.config.js +12 -0
- package/bin/ditto.js +88 -0
- package/jest.config.js +3 -0
- package/jsconfig.json +7 -0
- package/lib/add-project.js +23 -0
- package/lib/api.js +16 -0
- package/lib/config.js +84 -0
- package/lib/config.test.js +90 -0
- package/lib/consts.js +9 -0
- package/lib/init/init.js +34 -0
- package/lib/init/project.js +116 -0
- package/lib/init/project.test.js +83 -0
- package/lib/init/token.js +77 -0
- package/lib/init/token.test.js +47 -0
- package/lib/output.js +14 -0
- package/lib/pull.js +193 -0
- package/lib/pull.test.js +40 -0
- package/lib/remove-project.js +37 -0
- package/lib/utils/getSelectedProjects.js +41 -0
- package/lib/utils/projectsToText.js +14 -0
- package/lib/utils/promptForProject.js +50 -0
- package/package.json +54 -0
- package/pull_request_template.md +15 -0
- package/testfiles/en.json +5 -0
- package/testfiles/es.json +5 -0
- package/testfiles/fr.json +5 -0
- package/testing/.gitkeep +0 -0
- package/testing/fixtures/bad-yaml.yml +6 -0
- package/testing/fixtures/ditto-config-no-token +2 -0
- package/testing/fixtures/project-config-empty-projects.yml +1 -0
- package/testing/fixtures/project-config-no-id.yml +2 -0
- package/testing/fixtures/project-config-no-name.yml +2 -0
- package/testing/fixtures/project-config-working.yml +3 -0
package/.eslintrc.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
env: {
|
|
3
|
+
browser: true,
|
|
4
|
+
commonjs: true,
|
|
5
|
+
es2020: true,
|
|
6
|
+
'jest/globals': true,
|
|
7
|
+
},
|
|
8
|
+
extends: [
|
|
9
|
+
'airbnb-base',
|
|
10
|
+
],
|
|
11
|
+
parserOptions: {
|
|
12
|
+
ecmaVersion: 11,
|
|
13
|
+
},
|
|
14
|
+
plugins: ['jest'],
|
|
15
|
+
rules: {
|
|
16
|
+
'no-console': 'off',
|
|
17
|
+
'no-underscore-dangle': ['error', { allow: ['_id', '__get__'] }],
|
|
18
|
+
},
|
|
19
|
+
};
|
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Ditto CLI
|
|
2
|
+
|
|
3
|
+
The Ditto CLI helps teams integrate [Ditto](https://dittowords.com/) into their build processes. You can use it to pull copy into your codebase using the Ditto API right from the terminal.
|
|
4
|
+
|
|
5
|
+
Ditto allows for end-to-end syncing of text from mockups (Figma) all the way to production. For more information, visit [dittowords.com](http://dittowords.com).
|
|
6
|
+
|
|
7
|
+
## Getting Started
|
|
8
|
+
|
|
9
|
+
Install the Ditto CLI globally by doing the following:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
npm install -g @dittowords/cli
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then, run `ditto-cli` to finish setting up. You’ll be prompted to:
|
|
16
|
+
|
|
17
|
+
1. Provide your API key (found at [https://beta.dittowords.com/account/user](https://beta.dittowords.com/account/user) under **API Keys**).
|
|
18
|
+
2. Choose a Ditto project in your workspace to pull copy from. Only projects with **developer mode** enabled are accessible via the API.
|
|
19
|
+
|
|
20
|
+
Once you successfully provide that information, you’re ready to start fetching copy! You can set up the CLI in multiple directories by running the `ditto-cli` command and choosing a project to sync from.
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
### `pull`
|
|
25
|
+
|
|
26
|
+
**Usage:** `ditto-cli pull`
|
|
27
|
+
**Action:** To pull the latest text from the linked project into `ditto/text.json`.
|
|
28
|
+
|
|
29
|
+
The text will be pulled down as a structured JSON with IDs, and will locally overwrite what was previously in the file.
|
|
30
|
+
|
|
31
|
+
### `project`
|
|
32
|
+
|
|
33
|
+
**Usage:** `ditto-cli project`
|
|
34
|
+
|
|
35
|
+
**Action:** To change the Ditto project you want to pull copy from in the current directory. Running this will allow you to select a project from a list of projects in your workspace that have developer mode enabled.
|
|
36
|
+
|
|
37
|
+
The Ditto project that the directory is synced with is defined in `ditto/config.yml`, and you can also manually change the linked project there.
|
|
38
|
+
|
|
39
|
+
## Files
|
|
40
|
+
|
|
41
|
+
### `ditto/`
|
|
42
|
+
|
|
43
|
+
This folder is associated with your current working directory, so if you switch directories, we'll create a new folder there.
|
|
44
|
+
|
|
45
|
+
- `config.yml`
|
|
46
|
+
|
|
47
|
+
This stores the information on which Ditto project you've selected to pull copy from in this directory.
|
|
48
|
+
|
|
49
|
+
You can change which project is defined here using the `project` command, or by manually replacing the contents of this file. If you switch directories, you'll automatically be prompted to choose a project to pull from.
|
|
50
|
+
|
|
51
|
+
- `text.json`
|
|
52
|
+
|
|
53
|
+
The copy pulled from your Ditto project is saved to this file. Learn more about how this content is structured and how the IDs are generated by reading our guide here [LINK].
|
|
54
|
+
|
|
55
|
+
### `.config/ditto`
|
|
56
|
+
|
|
57
|
+
Your API key is saved to this file in your **root directory**. To change your API key, you'll need to open this file and replace the key manually.
|
|
58
|
+
|
|
59
|
+
## SDKs
|
|
60
|
+
|
|
61
|
+
Our SDKs make it easy to integrate the copy pulled from the Ditto CLI into your applications.
|
|
62
|
+
|
|
63
|
+
- [Ditto React SDK](https://www.npmjs.com/package/ditto-react) for React applications. Allows for easy querying of text, blocks, frames, and more.
|
|
64
|
+
|
|
65
|
+
## Feedback
|
|
66
|
+
|
|
67
|
+
Have feedback? We’d love to hear it! Message us at [support@dittowords.com](mailto:support@dittowords.com).
|
package/babel.config.js
ADDED
package/bin/ditto.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// This is the main entry point for the ditto-cli command.
|
|
3
|
+
const { program } = require('commander');
|
|
4
|
+
// to use V8's code cache to speed up instantiation time
|
|
5
|
+
require('v8-compile-cache');
|
|
6
|
+
|
|
7
|
+
const { init, needsInit } = require('../lib/init/init');
|
|
8
|
+
const pull = require('../lib/pull');
|
|
9
|
+
|
|
10
|
+
const addProject = require('../lib/add-project');
|
|
11
|
+
const removeProject = require('../lib/remove-project');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Catch and report unexpected error.
|
|
15
|
+
* @param {any} error The thrown error object.
|
|
16
|
+
* @returns {void}
|
|
17
|
+
*/
|
|
18
|
+
function quit(exitCode = 2) {
|
|
19
|
+
console.log('\nExiting Ditto CLI...\n');
|
|
20
|
+
process.exitCode = exitCode;
|
|
21
|
+
process.exit();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const setupCommands = () => {
|
|
25
|
+
program.name('ditto-cli');
|
|
26
|
+
program
|
|
27
|
+
.command('pull')
|
|
28
|
+
.description('Sync copy from Ditto into working directory')
|
|
29
|
+
.action(() => checkInit('pull'));
|
|
30
|
+
|
|
31
|
+
const projectDescription = 'Add a Ditto project to sync copy from'
|
|
32
|
+
const projectCommand = program
|
|
33
|
+
.command('project')
|
|
34
|
+
.description(projectDescription)
|
|
35
|
+
.action(() => checkInit('project'));
|
|
36
|
+
|
|
37
|
+
projectCommand
|
|
38
|
+
.command('add')
|
|
39
|
+
.description(projectDescription)
|
|
40
|
+
.action(() => checkInit('project'));
|
|
41
|
+
|
|
42
|
+
projectCommand
|
|
43
|
+
.command('remove')
|
|
44
|
+
.description('Stop syncing copy from a Ditto project')
|
|
45
|
+
.action(() => checkInit('project remove'));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const checkInit = async (command) => {
|
|
49
|
+
if (needsInit() && command !== 'project remove') {
|
|
50
|
+
try {
|
|
51
|
+
await init();
|
|
52
|
+
if (command === 'pull')
|
|
53
|
+
main(); // re-run to actually pull text now that init is finished
|
|
54
|
+
} catch (error) {
|
|
55
|
+
quit();
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
switch (command) {
|
|
59
|
+
case 'pull':
|
|
60
|
+
pull();
|
|
61
|
+
break;
|
|
62
|
+
case 'project':
|
|
63
|
+
case 'project add':
|
|
64
|
+
addProject();
|
|
65
|
+
break;
|
|
66
|
+
case 'project remove':
|
|
67
|
+
removeProject();
|
|
68
|
+
break;
|
|
69
|
+
case 'none':
|
|
70
|
+
setupCommands();
|
|
71
|
+
program.help();
|
|
72
|
+
break;
|
|
73
|
+
default:
|
|
74
|
+
quit();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const main = async () => {
|
|
80
|
+
if (process.argv.length <= 2 && process.argv[1].includes('ditto-cli')) {
|
|
81
|
+
await checkInit('none');
|
|
82
|
+
} else {
|
|
83
|
+
setupCommands();
|
|
84
|
+
}
|
|
85
|
+
program.parse(process.argv);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
main();
|
package/jest.config.js
ADDED
package/jsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const { collectAndSaveProject } = require('./init/project');
|
|
2
|
+
const projectsToText = require('./utils/projectsToText');
|
|
3
|
+
const getSelectedProjects = require('./utils/getSelectedProjects');
|
|
4
|
+
|
|
5
|
+
function quit(exitCode = 2) {
|
|
6
|
+
console.log('Project selection was not updated.');
|
|
7
|
+
process.exitCode = exitCode;
|
|
8
|
+
process.exit();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const addProject = async () => {
|
|
12
|
+
const projects = getSelectedProjects();
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
console.log(`\nYou're currently set up to sync text from the following projects: ${projectsToText(projects)}`);
|
|
16
|
+
await collectAndSaveProject(false);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.log(`\nSorry, there was an error adding a project to your workspace: `, error);
|
|
19
|
+
quit();
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
module.exports = addProject;
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const axios = require('axios').default;
|
|
2
|
+
|
|
3
|
+
const config = require('./config');
|
|
4
|
+
const consts = require('./consts');
|
|
5
|
+
|
|
6
|
+
function create(token) {
|
|
7
|
+
return axios.create({
|
|
8
|
+
baseURL: consts.API_HOST,
|
|
9
|
+
headers: {
|
|
10
|
+
Authorization: `token ${token}`,
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { create };
|
|
16
|
+
module.exports.default = create(config.getToken(consts.CONFIG_FILE, consts.API_HOST));
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const url = require('url');
|
|
4
|
+
|
|
5
|
+
const yaml = require('js-yaml');
|
|
6
|
+
|
|
7
|
+
function createFileIfMissing(filename) {
|
|
8
|
+
const dir = path.dirname(filename);
|
|
9
|
+
|
|
10
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir);
|
|
11
|
+
|
|
12
|
+
if (!fs.existsSync(filename)) {
|
|
13
|
+
fs.closeSync(fs.openSync(filename, 'w'));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readData(file, defaultData = {}) {
|
|
18
|
+
createFileIfMissing(file);
|
|
19
|
+
const fileContents = fs.readFileSync(file, 'utf8');
|
|
20
|
+
return yaml.safeLoad(fileContents) || defaultData;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writeData(file, data) {
|
|
24
|
+
createFileIfMissing(file);
|
|
25
|
+
const existingData = readData(file);
|
|
26
|
+
const yamlStr = yaml.safeDump({...existingData, ...data });
|
|
27
|
+
fs.writeFileSync(file, yamlStr, 'utf8');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function justTheHost(host) {
|
|
31
|
+
if (!host.includes('://')) return host;
|
|
32
|
+
return url.parse(host).hostname;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function deleteToken(file, host) {
|
|
36
|
+
const data = readData(file);
|
|
37
|
+
const hostParsed = justTheHost(host);
|
|
38
|
+
data[hostParsed] = [];
|
|
39
|
+
data[hostParsed][0] = {};
|
|
40
|
+
data[hostParsed][0].token = '';
|
|
41
|
+
writeData(file, data);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function saveToken(file, host, token) {
|
|
45
|
+
const data = readData(file);
|
|
46
|
+
const hostParsed = justTheHost(host);
|
|
47
|
+
data[hostParsed] = []; // only allow one token per host
|
|
48
|
+
data[hostParsed][0] = {};
|
|
49
|
+
data[hostParsed][0].token = token;
|
|
50
|
+
writeData(file, data);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getToken(file, host) {
|
|
54
|
+
const data = readData(file);
|
|
55
|
+
const hostEntry = data[justTheHost(host)];
|
|
56
|
+
if (!hostEntry) return undefined;
|
|
57
|
+
const { length } = hostEntry;
|
|
58
|
+
return hostEntry[length - 1].token;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function save(file, key, value) {
|
|
62
|
+
const data = readData(file);
|
|
63
|
+
let current = data;
|
|
64
|
+
const parts = key.split('.');
|
|
65
|
+
parts.slice(0, -1).forEach((part) => {
|
|
66
|
+
if (!(part in current)) {
|
|
67
|
+
current[part] = {};
|
|
68
|
+
current = current[part];
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
current[parts.slice(-1)] = value;
|
|
72
|
+
writeData(file, data);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
createFileIfMissing,
|
|
77
|
+
readData,
|
|
78
|
+
writeData,
|
|
79
|
+
justTheHost,
|
|
80
|
+
saveToken,
|
|
81
|
+
deleteToken,
|
|
82
|
+
getToken,
|
|
83
|
+
save,
|
|
84
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/* eslint-disable no-underscore-dangle */
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const tempy = require('tempy');
|
|
6
|
+
const yaml = require('js-yaml');
|
|
7
|
+
|
|
8
|
+
const config = require('./config');
|
|
9
|
+
|
|
10
|
+
const fakeHomedir = fs.mkdtempSync(path.join(__dirname, '../testing/tmp'));
|
|
11
|
+
|
|
12
|
+
describe('Config File', () => {
|
|
13
|
+
const expectedConfigDir = path.join(fakeHomedir, '.config');
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
if (!fs.existsSync(fakeHomedir)) fs.mkdirSync(fakeHomedir);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
fs.rmdirSync(fakeHomedir, { recursive: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('createFileIfMissing', () => {
|
|
24
|
+
const expectedConfigFile = path.join(expectedConfigDir, 'ditto');
|
|
25
|
+
|
|
26
|
+
it('creates a config file if the config dir is missing', () => {
|
|
27
|
+
config.createFileIfMissing(expectedConfigFile);
|
|
28
|
+
expect(fs.existsSync(expectedConfigFile)).toBeTruthy();
|
|
29
|
+
});
|
|
30
|
+
it("creates a config file if it's missing", () => {
|
|
31
|
+
fs.mkdirSync(expectedConfigDir);
|
|
32
|
+
config.createFileIfMissing(expectedConfigFile);
|
|
33
|
+
expect(fs.existsSync(expectedConfigFile)).toBeTruthy();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('does nothing if it already exists', () => {
|
|
37
|
+
fs.mkdirSync(expectedConfigDir);
|
|
38
|
+
fs.closeSync(fs.openSync(expectedConfigFile, 'w'));
|
|
39
|
+
config.createFileIfMissing(expectedConfigFile);
|
|
40
|
+
expect(fs.existsSync(expectedConfigFile)).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('getToken', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('Tokens in config files', () => {
|
|
52
|
+
const configFile = path.join(fakeHomedir, 'ditto');
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
config.createFileIfMissing(configFile);
|
|
56
|
+
config.saveToken(configFile, 'testing.dittowords.com', 'faketoken');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
fs.rmdirSync(fakeHomedir, { recursive: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('saveToken', () => {
|
|
64
|
+
it('creates a config file with config data', () => {
|
|
65
|
+
const fileContents = fs.readFileSync(configFile, 'utf8');
|
|
66
|
+
const configData = yaml.safeLoad(fileContents);
|
|
67
|
+
expect(configData['testing.dittowords.com']).toBeDefined();
|
|
68
|
+
expect(configData['testing.dittowords.com'][0].token).toEqual('faketoken');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('getToken', () => {
|
|
73
|
+
it('can retrieve the saved token value', () => {
|
|
74
|
+
expect(config.getToken(configFile, 'testing.dittowords.com')).toEqual('faketoken');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('save', () => {
|
|
80
|
+
let data;
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
const file = tempy.writeSync('');
|
|
83
|
+
config.save(file, 'test.setting', true);
|
|
84
|
+
data = config.readData(file);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('writes a setting correctly', () => {
|
|
88
|
+
expect(data.test.setting).toEqual(true);
|
|
89
|
+
});
|
|
90
|
+
});
|
package/lib/consts.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const homedir = require('os').homedir();
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
module.exports.API_HOST = process.env.DITTO_API_HOST || 'https://api.dittowords.com';
|
|
6
|
+
module.exports.CONFIG_FILE = process.env.DITTO_CONFIG_FILE || path.join(homedir, '.config', 'ditto');
|
|
7
|
+
module.exports.PROJECT_CONFIG_FILE = path.normalize(path.join('ditto', 'config.yml'));
|
|
8
|
+
module.exports.TEXT_FILE = path.normalize(path.join('ditto', 'text.json'));
|
|
9
|
+
module.exports.TEXT_DIR = path.normalize('ditto');
|
package/lib/init/init.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Related to initializing a user/environment to ditto.
|
|
2
|
+
// expected to be run once per project.
|
|
3
|
+
const boxen = require('boxen');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const getSelectedProjects = require('../utils/getSelectedProjects');
|
|
6
|
+
const projectsToText = require('../utils/projectsToText');
|
|
7
|
+
|
|
8
|
+
const { needsProjects, collectAndSaveProject } = require('./project');
|
|
9
|
+
const { needsToken, collectAndSaveToken } = require('./token');
|
|
10
|
+
|
|
11
|
+
const needsInit = () => needsToken() || needsProjects();
|
|
12
|
+
|
|
13
|
+
function welcome() {
|
|
14
|
+
const msg = chalk.white(`${chalk.bold('Welcome to the',
|
|
15
|
+
chalk.magentaBright('Ditto CLI'))}.
|
|
16
|
+
|
|
17
|
+
We're glad to have you here.`);
|
|
18
|
+
console.log(boxen(msg, { padding: 1 }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function init() {
|
|
22
|
+
welcome();
|
|
23
|
+
if (needsToken()) {
|
|
24
|
+
await collectAndSaveToken();
|
|
25
|
+
}
|
|
26
|
+
const selectedProjects = getSelectedProjects();
|
|
27
|
+
if (selectedProjects.length) {
|
|
28
|
+
console.log(`You're currently set up to sync text from the following projects: ${projectsToText(selectedProjects)}\n`);
|
|
29
|
+
} else {
|
|
30
|
+
await collectAndSaveProject(true);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { needsInit, init };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const ora = require("ora");
|
|
2
|
+
|
|
3
|
+
const api = require("../api").default;
|
|
4
|
+
const config = require("../config");
|
|
5
|
+
const consts = require("../consts");
|
|
6
|
+
const output = require("../output");
|
|
7
|
+
const { collectAndSaveToken } = require("../init/token");
|
|
8
|
+
const getSelectedProjects = require("../utils/getSelectedProjects");
|
|
9
|
+
const promptForProject = require("../utils/promptForProject");
|
|
10
|
+
|
|
11
|
+
function quit(exitCode = 2) {
|
|
12
|
+
console.log("\nExiting Ditto CLI...\n");
|
|
13
|
+
process.exitCode = exitCode;
|
|
14
|
+
process.exit();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function saveProject(file, name, id) {
|
|
18
|
+
const projects = [...getSelectedProjects(), { name, id }];
|
|
19
|
+
|
|
20
|
+
config.writeData(file, { projects });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function needsProjects(configFile) {
|
|
24
|
+
const projects = getSelectedProjects(configFile);
|
|
25
|
+
return !(projects && projects.length);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function askForAnotherToken() {
|
|
29
|
+
config.deleteToken(consts.CONFIG_FILE, consts.API_HOST);
|
|
30
|
+
const message =
|
|
31
|
+
"Looks like the API key you have saved no longer works. Please enter another one.";
|
|
32
|
+
await collectAndSaveToken(message);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function listProjects(token, projectsAlreadySelected) {
|
|
36
|
+
const spinner = ora("Fetching projects in your workspace...");
|
|
37
|
+
spinner.start();
|
|
38
|
+
|
|
39
|
+
let projects = [];
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
projects = await api.get("/project-names", {
|
|
43
|
+
headers: {
|
|
44
|
+
Authorization: `token ${token}`,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
} catch (e) {
|
|
48
|
+
spinner.stop();
|
|
49
|
+
throw e;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
spinner.stop();
|
|
53
|
+
|
|
54
|
+
return projects.data.filter(
|
|
55
|
+
({ id }) => !projectsAlreadySelected.some((project) => project.id === id)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function collectProject(token, initialize) {
|
|
60
|
+
const path = process.cwd();
|
|
61
|
+
if (initialize) {
|
|
62
|
+
console.log(
|
|
63
|
+
`Looks like are no Ditto projects selected for your current directory: ${output.info(
|
|
64
|
+
path
|
|
65
|
+
)}.`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const projectsAlreadySelected = getSelectedProjects();
|
|
70
|
+
const projects = await listProjects(token, projectsAlreadySelected);
|
|
71
|
+
|
|
72
|
+
if (!(projects && projects.length)) {
|
|
73
|
+
console.log("You're currently syncing all projects in your workspace.");
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return promptForProject({
|
|
78
|
+
projects,
|
|
79
|
+
message: initialize
|
|
80
|
+
? "Choose the project you'd like to sync text from"
|
|
81
|
+
: "Add a project",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function collectAndSaveProject(initialize = false) {
|
|
86
|
+
try {
|
|
87
|
+
const token = config.getToken(consts.CONFIG_FILE, consts.API_HOST);
|
|
88
|
+
const project = await collectProject(token, initialize);
|
|
89
|
+
if (!project) {
|
|
90
|
+
quit(0);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(
|
|
95
|
+
"\n" +
|
|
96
|
+
`Thanks for adding ${output.info(
|
|
97
|
+
project.name
|
|
98
|
+
)} to your selected projects.\n` +
|
|
99
|
+
`We saved your updated configuration to: ${output.info(
|
|
100
|
+
consts.PROJECT_CONFIG_FILE
|
|
101
|
+
)}\n`
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
saveProject(consts.PROJECT_CONFIG_FILE, project.name, project.id);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
console.log(e);
|
|
107
|
+
if (e.response && e.response.status === 404) {
|
|
108
|
+
await askForAnotherToken();
|
|
109
|
+
await collectAndSaveProject();
|
|
110
|
+
} else {
|
|
111
|
+
quit();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { needsProjects, collectAndSaveProject };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const rewire = require('rewire');
|
|
5
|
+
const yaml = require('js-yaml');
|
|
6
|
+
const { createFileIfMissing } = require('../config');
|
|
7
|
+
|
|
8
|
+
const project = rewire('./project');
|
|
9
|
+
|
|
10
|
+
const { needsProject } = project;
|
|
11
|
+
|
|
12
|
+
const saveProject = project.__get__('saveProject');
|
|
13
|
+
const parseResponse = project.__get__('parseResponse');
|
|
14
|
+
|
|
15
|
+
const fakeProjectDir = path.join(__dirname, '../../testing/tmp');
|
|
16
|
+
const badYaml = path.join(__dirname, '../../testing/fixtures/bad-yaml.yml');
|
|
17
|
+
const configEmptyProjects = path.join(__dirname, '../../testing/fixtures/bad-yaml.yml');
|
|
18
|
+
const configMissingName = path.join(__dirname, '../../testing/fixtures/project-config-no-name.yml');
|
|
19
|
+
const configMissingId = path.join(__dirname, '../../testing/fixtures/project-config-no-id.yml');
|
|
20
|
+
const configLegit = path.join(__dirname, '../../testing/fixtures/project-config-working.yml');
|
|
21
|
+
|
|
22
|
+
describe('saveProject', () => {
|
|
23
|
+
const configFile = path.join(fakeProjectDir, 'ditto/config.yml');
|
|
24
|
+
const projectName = 'My Amazing Project';
|
|
25
|
+
const projectId = '5f284259ce1d451b2eb2e23c';
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
if (!fs.existsSync(fakeProjectDir)) fs.mkdirSync(fakeProjectDir);
|
|
29
|
+
saveProject(configFile, projectName, projectId);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
fs.rmdirSync(fakeProjectDir, { recursive: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('creates a config file with config data', () => {
|
|
37
|
+
const fileContents = fs.readFileSync(configFile, 'utf8');
|
|
38
|
+
const data = yaml.safeLoad(fileContents);
|
|
39
|
+
expect(data.projects).toBeDefined();
|
|
40
|
+
expect(data.projects[0].name).toEqual(projectName);
|
|
41
|
+
expect(data.projects[0].id).toEqual(projectId);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('needsProject()', () => {
|
|
46
|
+
const configFile = path.join(fakeProjectDir, 'ditto/config.yml');
|
|
47
|
+
it('is true if there is no existing config file', () => {
|
|
48
|
+
expect(needsProject(configFile)).toBeTruthy();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('with a file that exists', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
if (!fs.existsSync(fakeProjectDir)) fs.mkdirSync(fakeProjectDir);
|
|
54
|
+
createFileIfMissing(configFile);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('is true if the file has no projects', () => {
|
|
58
|
+
expect(needsProject(configFile)).toBeTruthy();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it.each([
|
|
62
|
+
['the file is bad yaml', badYaml],
|
|
63
|
+
['no entries exist under projects', configEmptyProjects],
|
|
64
|
+
['an entry exists without a name', configMissingName],
|
|
65
|
+
['an entry exists without an id', configMissingId],
|
|
66
|
+
])('is true if %s', (_msg, file) => {
|
|
67
|
+
expect(fs.existsSync(file)).toBeTruthy();
|
|
68
|
+
expect(needsProject(file)).toBeTruthy();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('is false if a file exists with a propper project', () => {
|
|
72
|
+
expect(fs.existsSync(configLegit)).toBeTruthy();
|
|
73
|
+
expect(needsProject(configLegit)).toBeFalsy();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('parseResponse', () => {
|
|
79
|
+
const result = 'Large file test [90mhttp://localhost:3000/doc/5ea120d8b56b315ffddb0ef8[39m';
|
|
80
|
+
it('returns the id, name for a project result', () => {
|
|
81
|
+
expect(parseResponse(result)).toEqual({ id: '5ea120d8b56b315ffddb0ef8', name: 'Large file test' });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
const { prompt } = require('enquirer');
|
|
6
|
+
|
|
7
|
+
const api = require('../api');
|
|
8
|
+
const consts = require('../consts');
|
|
9
|
+
const output = require('../output');
|
|
10
|
+
const config = require('../config');
|
|
11
|
+
|
|
12
|
+
function needsToken(configFile, host = consts.API_HOST) {
|
|
13
|
+
const file = configFile || consts.CONFIG_FILE;
|
|
14
|
+
if (!fs.existsSync(file)) return true;
|
|
15
|
+
const configData = config.readData(file);
|
|
16
|
+
if (!configData[config.justTheHost(host)] || configData[config.justTheHost(host)][0].token === '') return true;
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Returns true if valid, otherwise an error message.
|
|
21
|
+
async function checkToken(token) {
|
|
22
|
+
const axios = api.create(token);
|
|
23
|
+
const endpoint = '/token-check';
|
|
24
|
+
|
|
25
|
+
const resOrError = await axios.get(endpoint).catch((error) => {
|
|
26
|
+
if (error.code === 'ENOTFOUND') {
|
|
27
|
+
return output.errorText(`Can't connect to API: ${output.url(error.hostname)}`);
|
|
28
|
+
}
|
|
29
|
+
if (error.response.status === 401 || error.response.status === 404) {
|
|
30
|
+
return output.errorText("This API key isn't valid. Please try another.");
|
|
31
|
+
}
|
|
32
|
+
return output.warnText("We're having trouble reaching the Ditto API.");
|
|
33
|
+
}).catch(() => output.errorText("Sorry! We're having trouble reaching the Ditto API."));
|
|
34
|
+
|
|
35
|
+
if (typeof resOrError === 'string') return resOrError;
|
|
36
|
+
|
|
37
|
+
if (resOrError.status === 200) return true;
|
|
38
|
+
|
|
39
|
+
return output.errorText("This API key isn't valid. Please try another.");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function collectToken(message) {
|
|
43
|
+
const blue = output.info;
|
|
44
|
+
const apiUrl = output.url('https://beta.dittowords.com/account/user');
|
|
45
|
+
const breadcrumbs = `${blue('User')}`;
|
|
46
|
+
const tokenDescription = message || `To get started, you'll need your Ditto API key. You can find this at: ${apiUrl} > ${breadcrumbs} under "${chalk.bold('API Keys')}".`;
|
|
47
|
+
console.log(tokenDescription);
|
|
48
|
+
|
|
49
|
+
const response = await prompt({
|
|
50
|
+
type: 'input',
|
|
51
|
+
name: 'token',
|
|
52
|
+
message: 'What is your API key?',
|
|
53
|
+
validate: (token) => (checkToken(token)),
|
|
54
|
+
});
|
|
55
|
+
return response.token;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function quit(exitCode = 2) {
|
|
59
|
+
console.log('API key was not saved.');
|
|
60
|
+
process.exitCode = exitCode;
|
|
61
|
+
process.exit();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function collectAndSaveToken(message = null) {
|
|
65
|
+
try {
|
|
66
|
+
const token = await collectToken(message);
|
|
67
|
+
console.log(`Thanks for authenticating. We'll save the key to: ${output.info(consts.CONFIG_FILE)}`);
|
|
68
|
+
output.nl();
|
|
69
|
+
|
|
70
|
+
config.saveToken(consts.CONFIG_FILE, consts.API_HOST, token);
|
|
71
|
+
return token;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
quit();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { needsToken, collectAndSaveToken };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const tempy = require('tempy');
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const { needsToken } = require('./token');
|
|
5
|
+
|
|
6
|
+
describe('needsToken()', () => {
|
|
7
|
+
it('is true if there is no config file', () => {
|
|
8
|
+
expect(needsToken(tempy.file())).toBeTruthy();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('with a config file', () => {
|
|
12
|
+
let configFile;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
configFile = tempy.writeSync('');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns true if empty', () => {
|
|
19
|
+
expect(needsToken(configFile, 'testing.dittowrods.com')).toBeTruthy();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('with some data', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
config.saveToken(configFile, 'badtesting.dittowords.com', 'faketoken');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('is true if there is no entries for our API host', () => {
|
|
28
|
+
expect(needsToken(configFile, 'testing.dittowords.com')).toBeTruthy();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('is false if we have a token listed', () => {
|
|
32
|
+
config.saveToken(configFile, 'testing.dittowords.com', 'faketoken');
|
|
33
|
+
expect(needsToken(configFile, 'testing.dittowords.com')).toBeFalsy();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('is true if there is no token listed', () => {
|
|
38
|
+
const configNoToken = '../../testing/fixtures/ditto-config-no-token';
|
|
39
|
+
expect(needsToken(configNoToken, 'testing.dittowords.com')).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('is strips the protocol when writing an entry', () => {
|
|
43
|
+
config.saveToken(configFile, 'https://testing.dittowords.com', 'faketoken');
|
|
44
|
+
expect(needsToken(configFile, 'testing.dittowords.com')).toBeFalsy();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
package/lib/output.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
|
|
3
|
+
const errorText = (msg) => chalk.magenta(msg);
|
|
4
|
+
const warnText = (msg) => chalk.yellow(msg);
|
|
5
|
+
const info = (msg) => chalk.blueBright(msg);
|
|
6
|
+
const success = (msg) => chalk.green(msg);
|
|
7
|
+
const url = (msg) => chalk.blueBright.underline(msg);
|
|
8
|
+
const subtle = (msg) => chalk.grey(msg);
|
|
9
|
+
const write = (msg) => chalk.white(msg);
|
|
10
|
+
const nl = () => console.log('\n');
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
errorText, warnText, url, info, write, subtle, nl, success,
|
|
14
|
+
};
|
package/lib/pull.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
const ora = require("ora");
|
|
5
|
+
|
|
6
|
+
const api = require("./api").default;
|
|
7
|
+
const config = require("./config");
|
|
8
|
+
const consts = require("./consts");
|
|
9
|
+
const output = require("./output");
|
|
10
|
+
const { collectAndSaveToken } = require("./init/token");
|
|
11
|
+
const projectsToText = require("./utils/projectsToText");
|
|
12
|
+
|
|
13
|
+
async function askForAnotherToken() {
|
|
14
|
+
config.deleteToken(consts.CONFIG_FILE, consts.API_HOST);
|
|
15
|
+
const message =
|
|
16
|
+
"Looks like the API key you have saved no longer works. Please enter another one.";
|
|
17
|
+
await collectAndSaveToken(message);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function downloadAndSaveVariant(variantApiId, projectIds, format) {
|
|
21
|
+
const params = { projectIds };
|
|
22
|
+
if (variantApiId) {
|
|
23
|
+
params.variant = variantApiId;
|
|
24
|
+
}
|
|
25
|
+
if (format) {
|
|
26
|
+
params.format = format;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { data } = await api.get("/projects", { params });
|
|
30
|
+
|
|
31
|
+
const filename = `${variantApiId || "base"}.json`;
|
|
32
|
+
const filepath = path.join(consts.TEXT_DIR, filename);
|
|
33
|
+
|
|
34
|
+
const dataString = JSON.stringify(data, null, 2);
|
|
35
|
+
|
|
36
|
+
fs.writeFileSync(filepath, dataString);
|
|
37
|
+
|
|
38
|
+
return getSavedMessage(filename);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function downloadAndSaveVariants(projectIds, format) {
|
|
42
|
+
const { data: variants } = await api.get("/variants");
|
|
43
|
+
|
|
44
|
+
const messages = await Promise.all([
|
|
45
|
+
downloadAndSaveVariant(null, projectIds, format),
|
|
46
|
+
...variants.map(({ apiID }) =>
|
|
47
|
+
downloadAndSaveVariant(apiID, projectIds, format)
|
|
48
|
+
),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
return messages.join("");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function downloadAndSaveBase(projectIds, format) {
|
|
55
|
+
const params = { projectIds };
|
|
56
|
+
if (format) {
|
|
57
|
+
params.format = format;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { data } = await api.get(`/projects`, { params });
|
|
61
|
+
const dataString = JSON.stringify(data, null, 2);
|
|
62
|
+
|
|
63
|
+
fs.writeFileSync(consts.TEXT_FILE, dataString);
|
|
64
|
+
|
|
65
|
+
return getSavedMessage("text.json");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getSavedMessage(file) {
|
|
69
|
+
return `Successfully saved to ${output.info(file)}.\n`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function cleanOutputFiles() {
|
|
73
|
+
if (!fs.existsSync(consts.TEXT_DIR)) {
|
|
74
|
+
fs.mkdirSync(consts.TEXT_DIR);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fileNames = fs.readdirSync(consts.TEXT_DIR);
|
|
78
|
+
fileNames.forEach((fileName) => {
|
|
79
|
+
if (/\.js(on)?$/.test(fileName)) {
|
|
80
|
+
fs.unlinkSync(path.resolve(consts.TEXT_DIR, fileName));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return "Cleaning old output files..\n";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Generates an index.js file that can be consumed
|
|
89
|
+
* by an SDK - this is a big DX improvement because
|
|
90
|
+
* it provides a single entry point to get all data
|
|
91
|
+
* (including variants) instead of having to import
|
|
92
|
+
* each generated file individually.
|
|
93
|
+
*/
|
|
94
|
+
function generateJsDriver() {
|
|
95
|
+
const fileNames = fs.readdirSync(consts.TEXT_DIR);
|
|
96
|
+
|
|
97
|
+
const driverExport = fileNames.reduce((obj, fileName) => {
|
|
98
|
+
const [name, extension] = fileName.split(".");
|
|
99
|
+
|
|
100
|
+
if (extension === "json") {
|
|
101
|
+
return {
|
|
102
|
+
...obj,
|
|
103
|
+
[name]: `require('./${fileName}')`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return obj;
|
|
108
|
+
}, {});
|
|
109
|
+
|
|
110
|
+
let dataString = `module.exports = ${JSON.stringify(driverExport, null, 2)}`
|
|
111
|
+
// remove quotes around require statements
|
|
112
|
+
.replace(/"require\((.*)\)"/g, "require($1)");
|
|
113
|
+
|
|
114
|
+
fs.writeFileSync(path.resolve(consts.TEXT_DIR, "index.js"), dataString, {
|
|
115
|
+
encoding: "utf8",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function downloadAndSave(projectConfig) {
|
|
120
|
+
const { projects, variants, format } = projectConfig;
|
|
121
|
+
|
|
122
|
+
const projectNames = [];
|
|
123
|
+
const projectIds = [];
|
|
124
|
+
|
|
125
|
+
projects.forEach(({ name, id }) => {
|
|
126
|
+
projectNames.push(name);
|
|
127
|
+
projectIds.push(id);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
let msg = `\nFetching the latest text from your selected projects: ${projectsToText(
|
|
131
|
+
projects
|
|
132
|
+
)}\n`;
|
|
133
|
+
|
|
134
|
+
const spinner = ora(msg);
|
|
135
|
+
spinner.start();
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
msg += cleanOutputFiles();
|
|
139
|
+
|
|
140
|
+
msg += variants
|
|
141
|
+
? await downloadAndSaveVariants(projectIds, format)
|
|
142
|
+
: await downloadAndSaveBase(projectIds, format);
|
|
143
|
+
|
|
144
|
+
msg += generateJsDriver();
|
|
145
|
+
|
|
146
|
+
msg += `\n${output.success("Done")}!`;
|
|
147
|
+
|
|
148
|
+
spinner.stop();
|
|
149
|
+
return console.log(msg);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
spinner.stop();
|
|
152
|
+
let error = e.message;
|
|
153
|
+
if (e.response && e.response.status === 404) {
|
|
154
|
+
await askForAnotherToken();
|
|
155
|
+
pull();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (e.response && e.response.status === 401) {
|
|
159
|
+
error = "You don't have access to the selected projects";
|
|
160
|
+
msg = `${output.errorText(error)}.\nChoose others using the ${output.info(
|
|
161
|
+
"project"
|
|
162
|
+
)} command, or update your API key.`;
|
|
163
|
+
return console.log(msg);
|
|
164
|
+
}
|
|
165
|
+
if (e.response && e.response.status === 403) {
|
|
166
|
+
error =
|
|
167
|
+
"One or more of the requested projects don't have Developer Mode enabled";
|
|
168
|
+
msg = `${output.errorText(
|
|
169
|
+
error
|
|
170
|
+
)}.\nPlease choose different projects using the ${output.info(
|
|
171
|
+
"project"
|
|
172
|
+
)} command, or turn on Developer Mode for all selected projects. Learn more here: ${output.subtle(
|
|
173
|
+
"https://www.dittowords.com/docs/ditto-developer-mode"
|
|
174
|
+
)}.`;
|
|
175
|
+
return console.log(msg);
|
|
176
|
+
}
|
|
177
|
+
if (e.response && e.response.status === 400) {
|
|
178
|
+
error = "projects not found";
|
|
179
|
+
}
|
|
180
|
+
msg = `We hit an error fetching text from the projects: ${output.errorText(
|
|
181
|
+
error
|
|
182
|
+
)}.\nChoose others using the ${output.info("project")} command.`;
|
|
183
|
+
return console.log(msg);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function pull() {
|
|
188
|
+
const token = config.getToken(consts.CONFIG_FILE, consts.API_HOST);
|
|
189
|
+
const pConfig = config.readData(consts.PROJECT_CONFIG_FILE);
|
|
190
|
+
return downloadAndSave(pConfig, token);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = pull;
|
package/lib/pull.test.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const tempy = require('tempy');
|
|
2
|
+
const rewire = require('rewire');
|
|
3
|
+
|
|
4
|
+
const pull = rewire('./pull');
|
|
5
|
+
|
|
6
|
+
const shouldOverwrite = pull.__get__('shouldOverwrite');
|
|
7
|
+
|
|
8
|
+
describe('shouldOverwrite', () => {
|
|
9
|
+
let config;
|
|
10
|
+
let configOverwrite;
|
|
11
|
+
let configNever;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
config = { settings: { } };
|
|
15
|
+
configOverwrite = { settings: { overwrite: true } };
|
|
16
|
+
configNever = { settings: { overwrite: false } };
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns true if there's no file", () => {
|
|
20
|
+
expect(shouldOverwrite(config, '.doesnotexist')).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('when the file exists', () => {
|
|
24
|
+
let existingFile;
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
existingFile = tempy.writeSync('');
|
|
27
|
+
});
|
|
28
|
+
it("returns 'ask' if there is no config setting", () => {
|
|
29
|
+
expect(shouldOverwrite(config, existingFile)).toEqual('ASK');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns true if config says overwrite', () => {
|
|
33
|
+
expect(shouldOverwrite(configOverwrite, existingFile)).toEqual(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns false if config says don't overwrite", () => {
|
|
37
|
+
expect(shouldOverwrite(configNever, existingFile)).toEqual(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const config = require('./config');
|
|
2
|
+
const consts = require('./consts');
|
|
3
|
+
const output = require('./output');
|
|
4
|
+
const getSelectedProjects = require("./utils/getSelectedProjects");
|
|
5
|
+
const promptForProject = require("./utils/promptForProject");
|
|
6
|
+
|
|
7
|
+
async function removeProject() {
|
|
8
|
+
const projects = getSelectedProjects();
|
|
9
|
+
if (!projects.length) {
|
|
10
|
+
console.log(
|
|
11
|
+
"\n" +
|
|
12
|
+
"No projects found in your workspace.\n" +
|
|
13
|
+
`Try adding one with: ${output.info("ditto-cli project add")}\n`);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const projectToRemove = await promptForProject({
|
|
18
|
+
projects,
|
|
19
|
+
message: "Select a project to remove"
|
|
20
|
+
});
|
|
21
|
+
if (!projectToRemove)
|
|
22
|
+
return;
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
config.writeData(
|
|
26
|
+
consts.PROJECT_CONFIG_FILE,
|
|
27
|
+
{ projects: projects.filter(({ id }) => id !== projectToRemove.id)}
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
console.log(
|
|
31
|
+
`\n${output.info(projectToRemove.name)} has been removed from your selected projects. ` +
|
|
32
|
+
`\nWe saved your updated configuration to: ${output.info(consts.PROJECT_CONFIG_FILE)}` +
|
|
33
|
+
"\n"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = removeProject;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const yaml = require('js-yaml');
|
|
3
|
+
|
|
4
|
+
const { PROJECT_CONFIG_FILE } = require('../consts');
|
|
5
|
+
|
|
6
|
+
function yamlToJson(_yaml) {
|
|
7
|
+
try {
|
|
8
|
+
return yaml.safeLoad(_yaml);
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
if (e instanceof YAMLException) {
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
throw e;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns an array containing all valid projects ({ id, name })
|
|
22
|
+
* currently contained in the project config file.
|
|
23
|
+
*/
|
|
24
|
+
function getSelectedProjects(configFile = PROJECT_CONFIG_FILE) {
|
|
25
|
+
if (!fs.existsSync(configFile))
|
|
26
|
+
return [];
|
|
27
|
+
|
|
28
|
+
const contentYaml = fs.readFileSync(configFile, 'utf8');
|
|
29
|
+
const contentJson = yamlToJson(contentYaml);
|
|
30
|
+
|
|
31
|
+
if (!(
|
|
32
|
+
contentJson &&
|
|
33
|
+
contentJson.projects
|
|
34
|
+
)) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return contentJson.projects.filter(({ name, id }) => name && id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = getSelectedProjects;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const output = require('../output');
|
|
2
|
+
|
|
3
|
+
function projectsToText(projects) {
|
|
4
|
+
return projects.reduce((outputString, { name, id }) =>
|
|
5
|
+
outputString + (
|
|
6
|
+
"\n" +
|
|
7
|
+
"- " + output.info(name) +
|
|
8
|
+
" " +
|
|
9
|
+
output.subtle('https://beta.dittowords.com/doc/' + id)
|
|
10
|
+
), ""
|
|
11
|
+
) + "\n"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = projectsToText;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const { AutoComplete } = require('enquirer');
|
|
2
|
+
|
|
3
|
+
const output = require('../output');
|
|
4
|
+
|
|
5
|
+
function formatProjectChoice(project) {
|
|
6
|
+
return project.name + " " + output.subtle(
|
|
7
|
+
project.url || `https://beta.dittowords.com/doc/${project.id}`
|
|
8
|
+
)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseResponse(response) {
|
|
12
|
+
if (!response) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const [, name, id] = response.split(/^(.*)\s.*http.*\/(\w+).*$/);
|
|
17
|
+
|
|
18
|
+
if (id === 'all') {
|
|
19
|
+
return { name, id: 'ditto_component_library'}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return { name, id };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function promptForProject({ message, projects, limit = 10 }) {
|
|
26
|
+
output.nl();
|
|
27
|
+
|
|
28
|
+
const choices = projects.map(formatProjectChoice);
|
|
29
|
+
const prompt = new AutoComplete({
|
|
30
|
+
name: 'project',
|
|
31
|
+
message,
|
|
32
|
+
limit,
|
|
33
|
+
choices,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
let response;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
response = await prompt.run();
|
|
40
|
+
}
|
|
41
|
+
// this catch handles the case where someone presses
|
|
42
|
+
// Ctrl + C to kill the AutoComplete process
|
|
43
|
+
catch (e) {
|
|
44
|
+
response = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return parseResponse(response);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = promptForProject;
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dittowords/cli",
|
|
3
|
+
"version": "1.1.1-beta.0",
|
|
4
|
+
"description": "Command Line Interface for Ditto (dittowords.com).",
|
|
5
|
+
"main": "bin/index.js",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/dittowords/cli.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/dittowords/cli/issues"
|
|
12
|
+
},
|
|
13
|
+
"author": "Ditto Tech Inc.",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"ditto",
|
|
17
|
+
"dittowords",
|
|
18
|
+
"copy",
|
|
19
|
+
"microcopy",
|
|
20
|
+
"product",
|
|
21
|
+
"cli",
|
|
22
|
+
"api"
|
|
23
|
+
],
|
|
24
|
+
"bin": {
|
|
25
|
+
"ditto-cli": "./bin/ditto.js"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@babel/core": "^7.11.4",
|
|
29
|
+
"@babel/preset-env": "^7.11.0",
|
|
30
|
+
"@types/jest": "^26.0.9",
|
|
31
|
+
"babel-jest": "^26.3.0",
|
|
32
|
+
"eslint": "^7.6.0",
|
|
33
|
+
"eslint-config-airbnb-base": "^14.2.0",
|
|
34
|
+
"eslint-plugin-import": "^2.22.0",
|
|
35
|
+
"eslint-plugin-jest": "^23.20.0",
|
|
36
|
+
"jest": "^26.3.0",
|
|
37
|
+
"rewire": "^5.0.0",
|
|
38
|
+
"source-map": "^0.7.3",
|
|
39
|
+
"tempy": "^0.6.0",
|
|
40
|
+
"tsconfig-paths": "^3.9.0",
|
|
41
|
+
"typescript": "^4.0.2"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"axios": "^0.19.2",
|
|
45
|
+
"boxen": "^4.2.0",
|
|
46
|
+
"chalk": "^4.1.0",
|
|
47
|
+
"commander": "^6.1.0",
|
|
48
|
+
"enquirer": "^2.3.6",
|
|
49
|
+
"faker": "^5.1.0",
|
|
50
|
+
"js-yaml": "^3.14.0",
|
|
51
|
+
"ora": "^5.0.0",
|
|
52
|
+
"v8-compile-cache": "^2.1.1"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
## Overview
|
|
2
|
+
<!--- What does this PR do? --->
|
|
3
|
+
|
|
4
|
+
## Context
|
|
5
|
+
<!--- Any context about why you are creating this PR? Notion doc, screenshot, conversation, etc. --->
|
|
6
|
+
|
|
7
|
+
## Screenshots
|
|
8
|
+
<!--- Include some screenshots of any visual changes you made to make it easier for the reviewer to understand what changes were made --->
|
|
9
|
+
|
|
10
|
+
## Test Plan
|
|
11
|
+
Testing successfully completed in <env> via:
|
|
12
|
+
- [ ] test 1
|
|
13
|
+
- [ ] test 2
|
|
14
|
+
- [ ] test ...
|
|
15
|
+
- [ ] test n
|
package/testing/.gitkeep
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
projects:
|