@verdnatura/myt 1.8.6 → 2.0.2

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.
Files changed (51) hide show
  1. package/README.md +80 -52
  2. package/assets/myt.default.yml +7 -0
  3. package/assets/structure.sql +1 -1
  4. package/{server/Dockerfile.client → docker/client/Dockerfile} +3 -3
  5. package/docker/server/Dockerfile +37 -0
  6. package/docker/server/Dockerfile.base +6 -0
  7. package/docker/server/Dockerfile.myt +35 -0
  8. package/docker/server/docker-init.sh +36 -0
  9. package/{server → docker/server}/docker-start.sh +1 -1
  10. package/lib/command.js +14 -6
  11. package/lib/docker.js +60 -34
  12. package/lib/dumper.js +11 -9
  13. package/lib/exporter-engine.js +4 -4
  14. package/lib/server.js +2 -8
  15. package/myt-apply.js +203 -0
  16. package/myt-build.js +283 -0
  17. package/myt-clean.js +15 -11
  18. package/myt-create.js +19 -11
  19. package/myt-dump.js +41 -36
  20. package/myt-fixtures.js +6 -6
  21. package/myt-init.js +5 -3
  22. package/myt-pull.js +12 -11
  23. package/myt-push.js +99 -96
  24. package/myt-run.js +28 -144
  25. package/myt-start.js +4 -4
  26. package/myt-version.js +13 -13
  27. package/myt.js +285 -149
  28. package/package.json +4 -3
  29. package/prebuilts/README.md +15 -0
  30. package/prebuilts/nodegit-v0.28.0-alpha.36-node-v127-linux-x64.tar.gz +0 -0
  31. package/template/.dockerignore +5 -0
  32. package/template/myt.config.yml +1 -4
  33. package/template/package.json +1 -1
  34. package/template/realms/.keep +0 -0
  35. package/template/remotes/docker.ini +1 -1
  36. package/template/remotes/local.ini +1 -1
  37. package/template/remotes/socket.ini +3 -0
  38. package/template/structure/.dump/structure.sql +32 -0
  39. package/server/Dockerfile.base +0 -1
  40. package/server/Dockerfile.dump +0 -16
  41. package/server/Dockerfile.server +0 -16
  42. package/server/docker-import.sh +0 -10
  43. package/server/docker-init.sh +0 -24
  44. /package/{server → docker/client}/docker-dump.sh +0 -0
  45. /package/{server → docker/client}/docker-fixtures.sh +0 -0
  46. /package/{server → docker/server}/docker.cnf +0 -0
  47. /package/template/{dump/Dockerfile.base → docker/Dockerfile} +0 -0
  48. /package/template/{dump/fixtures.after.sql → fixtures/after.sql} +0 -0
  49. /package/template/{dump/fixtures.before.sql → fixtures/before.sql} +0 -0
  50. /package/template/{dump/dump.after.sql → structure/after.sql} +0 -0
  51. /package/template/{dump/dump.before.sql → structure/before.sql} +0 -0
package/myt-apply.js ADDED
@@ -0,0 +1,203 @@
1
+ const Myt = require('./myt');
2
+ const Command = require('./lib/command');
3
+ const Push = require('./myt-push');
4
+ const fs = require('fs-extra');
5
+ const path = require('path');
6
+ const connExt = require('./lib/conn');
7
+ const SqlString = require('sqlstring');
8
+ const spawn = require('child_process').spawn;
9
+
10
+ class Apply extends Command {
11
+ static usage = {
12
+ description: 'Initialize database',
13
+ params: {
14
+ structure: 'Apply only structure',
15
+ changes: 'Apply only changes',
16
+ realm: 'Name of fixture realm to use',
17
+ docker: 'Whether to load git changes from file',
18
+ load: 'Commit SHA to save'
19
+ },
20
+ operand: 'realm'
21
+ };
22
+
23
+ static args = {
24
+ alias: {
25
+ structure: 's',
26
+ changes: 'c',
27
+ realm: 'm',
28
+ docker: 'k',
29
+ load: 'l'
30
+ },
31
+ boolean: [
32
+ 'structure',
33
+ 'changes',
34
+ 'docker'
35
+ ],
36
+ string: [
37
+ 'realm',
38
+ 'load'
39
+ ],
40
+ default: {
41
+ remote: 'socket'
42
+ }
43
+ };
44
+
45
+ static reporter = {
46
+ mockingDate: 'Mocking date functions.',
47
+ applyingFixtures: 'Applying fixtures.',
48
+ applyingRealm: 'Applying realm fixtures.',
49
+ creatingTriggers: 'Creating triggers.',
50
+ };
51
+
52
+ async _run(myt, ctx, cfg, opts) {
53
+ const {
54
+ structureDir,
55
+ dumpDir,
56
+ fixturesDir
57
+ } = ctx;
58
+
59
+ const {triggersImport} = cfg;
60
+
61
+ const conn = await myt.createConnection();
62
+ const applyAll = !(opts.structure || opts.changes);
63
+
64
+ if (ctx.isProtectedRemote)
65
+ throw new Error('Cannot apply to protected remote');
66
+
67
+ if (applyAll || opts.structure) {
68
+ await this.importFile(path.join(structureDir, 'before.sql'));
69
+
70
+ const importFiles = [
71
+ 'structure.sql',
72
+ 'data.sql',
73
+ ];
74
+
75
+ if (triggersImport == 'before')
76
+ importFiles.push('triggers.sql');
77
+
78
+ importFiles.push('privileges.sql');
79
+
80
+ for (const file of importFiles)
81
+ await this.importFile(path.join(dumpDir, file), true);
82
+
83
+ await this.importFile(path.join(structureDir, 'after.sql'));
84
+
85
+ // Mock date functions
86
+
87
+ this.emit('mockingDate');
88
+ const mockDateScript = path.join(structureDir, 'mock-date.sql');
89
+
90
+ if (cfg.mockDate) {
91
+ if (!await fs.pathExists(mockDateScript))
92
+ throw new Error(`Date mock enabled but mock script does not exist: ${mockDateScript}`);
93
+
94
+ let sql = await fs.readFile(mockDateScript, 'utf8');
95
+ sql = sql.replace(/@mockDate/g, SqlString.escape(cfg.mockDate));
96
+ await connExt.multiQuery(conn, sql);
97
+ }
98
+ }
99
+
100
+ if (applyAll || opts.changes) {
101
+ // Apply changes
102
+
103
+ await myt.run(Push, {
104
+ triggers: triggersImport == 'after',
105
+ docker: true,
106
+ load: opts.load,
107
+ commit: true
108
+ });
109
+
110
+ // Apply fixtures
111
+
112
+ this.emit('applyingFixtures');
113
+ const fixturesFiles = [
114
+ ['before.sql'],
115
+ ['.dump.sql', true],
116
+ ['after.sql'],
117
+ ['local.sql'],
118
+ ];
119
+ for (const [file, force] of fixturesFiles)
120
+ await this.importFile(path.join(fixturesDir, file), force);
121
+
122
+ // Apply realms
123
+
124
+ if (opts.realm) {
125
+ this.emit('applyingRealm');
126
+ const realmDir = `realms/${opts.realm}`;
127
+ let realmFiles = await fs.readdir(realmDir);
128
+ realmFiles = realmFiles.map(file => path.parse(file).name);
129
+ for (const file of realmFiles) {
130
+ await this.importFile(path.join(realmDir, `${file}.sql`));
131
+ }
132
+ }
133
+
134
+ // Create triggers
135
+
136
+ if (triggersImport == 'after') {
137
+ this.emit('creatingTriggers');
138
+
139
+ for (const schema of cfg.schemas) {
140
+ const triggersPath = `${ctx.routinesDir}/${schema}/triggers`;
141
+ if (!await fs.pathExists(triggersPath))
142
+ continue;
143
+
144
+ const triggersDir = await fs.readdir(triggersPath);
145
+ for (const triggerFile of triggersDir)
146
+ await connExt.queryFromFile(conn, `${triggersPath}/${triggerFile}`);
147
+ }
148
+ }
149
+
150
+ await conn.end();
151
+ }
152
+ }
153
+
154
+ async importFile(file, force) {
155
+ const {cfg, ctx} = this;
156
+
157
+ if (!await fs.exists(file)) {
158
+ if (cfg.debug)
159
+ console.debug('Import:', `${file} does not exist, ignoring`);
160
+ return;
161
+ }
162
+
163
+ const iniPath = path.join(ctx.mytDir, 'remotes', ctx.iniFile);
164
+ const execArgs = [
165
+ `--defaults-file=${iniPath}`,
166
+ '--default-character-set=utf8',
167
+ '--comments',
168
+ ];
169
+
170
+ if (force)
171
+ execArgs.push('--force');
172
+
173
+ let stdio;
174
+ const mysqlBin = 'mariadb';
175
+ if (cfg.debug === true) {
176
+ const quotedArgs = execArgs
177
+ .map(x => /\s/g.test(x) ? `"${x}"` : x)
178
+ .join(' ');
179
+ console.debug('Command:', `${mysqlBin} ${quotedArgs} < ${file}`.yellow);
180
+
181
+ stdio = ['pipe', 'inherit', 'inherit'];
182
+ } else {
183
+ stdio = ['pipe', 'ignore', 'ignore'];
184
+ }
185
+
186
+ const child = spawn(mysqlBin, execArgs, {stdio});
187
+ fs.createReadStream(file).pipe(child.stdin);
188
+
189
+ return await new Promise((resolve, reject) => {
190
+ child.on('exit', code => {
191
+ if (code !== 0)
192
+ reject(new Error(`${mysqlBin} exit code ${code}`));
193
+ else
194
+ resolve(code);
195
+ });
196
+ });
197
+ }
198
+ }
199
+
200
+ module.exports = Apply;
201
+
202
+ if (require.main === module)
203
+ new Myt().cli(Apply);
package/myt-build.js ADDED
@@ -0,0 +1,283 @@
1
+ const Myt = require('./myt');
2
+ const Command = require('./lib/command');
3
+ const docker = require('./lib/docker');
4
+ const fs = require('fs-extra');
5
+ const path = require('path');
6
+ const {hashElement} = require('folder-hash');
7
+ const {createHash} = require('crypto');
8
+
9
+ class Build extends Command {
10
+ static usage = {
11
+ description: 'Build local database server container',
12
+ params: {
13
+ name: 'Built image name',
14
+ tag: 'Built image tag',
15
+ force: 'Whether to force build',
16
+ realm: 'Name of fixture realm to use'
17
+ },
18
+ operand: 'realm'
19
+ };
20
+
21
+ static args = {
22
+ alias: {
23
+ name: 'n',
24
+ tag: 't',
25
+ force: 'f',
26
+ realm: 'm'
27
+ },
28
+ string: [
29
+ 'name',
30
+ 'tag',
31
+ 'realm'
32
+ ],
33
+ boolean: [
34
+ 'force'
35
+ ]
36
+ };
37
+
38
+ static reporter = {
39
+ buildingBaseImages: 'Building base images.',
40
+ buildingServerImage: 'Building server image.',
41
+ serverImageTag: function(tag) {
42
+ console.log('Image:', tag);
43
+ }
44
+ };
45
+
46
+ async _run(myt, ctx, cfg, opts) {
47
+ const {
48
+ dumpDir,
49
+ dockerDir,
50
+ version,
51
+ subdir
52
+ } = ctx;
53
+
54
+ const {
55
+ realm,
56
+ force
57
+ } = opts;
58
+
59
+ const serverDir = path.join(__dirname, 'docker/server');
60
+
61
+ if (!await fs.pathExists(`${dumpDir}/structure.sql`))
62
+ throw new Error('Dump file not found');
63
+
64
+ // Initialize
65
+
66
+ const imageLabels = [`myt.version=${version}`];
67
+ const tagHash = createHash('sha1');
68
+ const repo = await myt.openRepo();
69
+
70
+ const commit = await repo.getHeadCommit();
71
+ const commitSha = commit?.sha() || '';
72
+ tagHash.update(commitSha);
73
+ imageLabels.push(`myt.commit-sha=${commitSha}`);
74
+
75
+ // Get changed files hash
76
+
77
+ const repoExt = require('./lib/repo');
78
+ const regex = subdir && new RegExp(`^${subdir}\/(.+)`);
79
+ const gitChanges = new Set();
80
+ const dirnames = new Set();
81
+ const {sep} = path;
82
+
83
+ async function pushChanges(diff) {
84
+ if (!diff) return;
85
+ const patches = await diff.patches();
86
+ for (const patch of patches) {
87
+ let filePath = patch.newFile().path();
88
+ if (regex) {
89
+ const match = filePath.match(regex);
90
+ if (!match) continue;
91
+ }
92
+ gitChanges.add(filePath);
93
+
94
+ const parts = path.dirname(filePath).split(sep);
95
+ for(let i = 0; i < parts.length; i++)
96
+ dirnames.add(parts.slice(0, i + 1).join(sep));
97
+ }
98
+ }
99
+
100
+ await pushChanges(await repoExt.getUnstaged(repo));
101
+ await pushChanges(await repoExt.getStaged(repo));
102
+
103
+ if (cfg.debug)
104
+ console.debug('Uncommited:'.magenta, gitChanges);
105
+
106
+ let changesSha;
107
+ if (gitChanges.size) {
108
+ const {hash} = await hashElement(subdir, {
109
+ algo: 'sha1',
110
+ encoding: 'hex',
111
+ folders: {
112
+ include: [...dirnames],
113
+ matchBasename: false,
114
+ matchPath: true
115
+ },
116
+ files: {
117
+ include: [...gitChanges],
118
+ matchBasename: false,
119
+ matchPath: true
120
+ }
121
+ });
122
+
123
+ changesSha = hash;
124
+ tagHash.update(changesSha);
125
+ imageLabels.push(`myt.changes-sha=${changesSha}`);
126
+ }
127
+
128
+ if (realm)
129
+ imageLabels.push(`myt.realm=${realm}`);
130
+
131
+ // Get image tag
132
+
133
+ const tagSha = tagHash.digest('hex');
134
+ const shortSha = tagSha.substring(0, cfg.gitShortLen);
135
+
136
+ let imageTag = shortSha;
137
+ if (realm) imageTag += `-${realm}`;
138
+
139
+ const imageName = opts.name || cfg.code;
140
+ const tag = `${imageName}:${imageTag}`;
141
+
142
+ // Check for existent image
143
+
144
+ let labels;
145
+ try {
146
+ labels = await docker.inspect(tag, {
147
+ format: '{{json .Config.Labels}}'
148
+ });
149
+ } catch (err) {
150
+ if (err.code !== 1) throw err;
151
+ labels = {};
152
+ }
153
+
154
+ const useCache = !force
155
+ && labels['myt.version'] == version
156
+ && labels['myt.commit-sha'] == commitSha
157
+ && (
158
+ (!changesSha && !labels['myt.changes-sha'])
159
+ || (labels['myt.changes-sha'] == changesSha)
160
+ )
161
+ && (
162
+ (!realm && !labels['myt.realm'])
163
+ || (labels['myt.realm'] == realm)
164
+ );
165
+
166
+ if (!useCache) {
167
+ // Build base server image
168
+
169
+ const baseTag = `myt/base:${version}`;
170
+ const mytTag = `myt/app:${version}`;
171
+
172
+ let baseLabels;
173
+ try {
174
+ baseLabels = await docker.inspect(baseTag,
175
+ {format: '{{json .Config.Labels}}'}
176
+ );
177
+ } catch (err) {
178
+ if (err.code !== 1) throw err;
179
+ }
180
+
181
+ let buildBase = !baseLabels;
182
+
183
+ const buildBaseLabels = [];
184
+ const dbDockerfile = path.join(dockerDir, 'Dockerfile');
185
+ const dbDockerExists = await fs.pathExists(dbDockerfile);
186
+
187
+ if (!buildBase && dbDockerExists) {
188
+ const {hash} = await hashElement(dockerDir, {
189
+ algo: 'sha1',
190
+ encoding: 'hex',
191
+ });
192
+
193
+ buildBase = baseLabels['myt.server-sha'] != hash;
194
+ buildBaseLabels.push(`myt.server-sha=${hash}`);
195
+ }
196
+
197
+ if (buildBase || force) {
198
+ this.emit('buildingBaseImages');
199
+
200
+ let dbTag;
201
+ if (dbDockerExists) {
202
+ dbTag = `myt/db:${version}`;
203
+ await docker.build(dockerDir, {
204
+ tag: dbTag,
205
+ file: dbDockerfile,
206
+ label: buildBaseLabels,
207
+ }, cfg.debug);
208
+ } else {
209
+ dbTag = cfg.dbImageTag;
210
+ }
211
+
212
+ const buildArgs = [];
213
+ if (dbTag)
214
+ buildArgs.push(`DB_TAG=${dbTag}`);
215
+
216
+ await docker.build(__dirname, {
217
+ tag: baseTag,
218
+ file: path.join(serverDir, 'Dockerfile.base'),
219
+ buildArg: buildArgs,
220
+ label: buildBaseLabels,
221
+ }, cfg.debug);
222
+
223
+ await docker.build(__dirname, {
224
+ tag: mytTag,
225
+ file: path.join(serverDir, 'Dockerfile.myt'),
226
+ buildArg: `BASE_TAG=${baseTag}`,
227
+ label: buildBaseLabels,
228
+ }, cfg.debug);
229
+ }
230
+
231
+ // Build dump image
232
+
233
+ this.emit('buildingServerImage');
234
+
235
+ let dbCommit;
236
+ const versionFile = path.join(dumpDir, 'version.json');
237
+ if (await fs.exists(versionFile)) {
238
+ const dbVersion = JSON.parse(await fs.readFile(versionFile, 'utf8'));
239
+ dbCommit = dbVersion.gitCommit;
240
+ }
241
+ const changes = await myt.getChanges(dbCommit);
242
+
243
+ const changesFile = path.join(ctx.routinesDir, '.changes.json');
244
+ await fs.writeFile(
245
+ changesFile,
246
+ JSON.stringify(changes, null, 1)
247
+ );
248
+
249
+ try {
250
+ const buildArgs = [
251
+ `ROOT_PASS=${cfg.rootPassword}`,
252
+ `BASE_TAG=${baseTag}`,
253
+ `MYT_TAG=${mytTag}`,
254
+ `MYT_COMMIT=${commitSha}`
255
+ ];
256
+
257
+ if (realm)
258
+ buildArgs.push(`MYT_REALM=${realm}`);
259
+
260
+ const tags = [tag, `${imageName}:latest`];
261
+ if (opts.tag)
262
+ tags.push(`${imageName}:${opts.tag}`);
263
+
264
+ await docker.build(ctx.mytDir, {
265
+ tag: tags,
266
+ file: path.join(serverDir, 'Dockerfile'),
267
+ label: imageLabels,
268
+ buildArg: buildArgs
269
+ }, cfg.debug);
270
+ } finally {
271
+ await fs.unlink(changesFile);
272
+ }
273
+ }
274
+
275
+ this.emit('serverImageTag', tag);
276
+ return tag;
277
+ }
278
+ }
279
+
280
+ module.exports = Build;
281
+
282
+ if (require.main === module)
283
+ new Myt().cli(Build);
package/myt-clean.js CHANGED
@@ -14,7 +14,7 @@ class Clean extends Command {
14
14
  }
15
15
  };
16
16
 
17
- static opts = {
17
+ static args = {
18
18
  alias: {
19
19
  purge: 'p'
20
20
  },
@@ -41,15 +41,17 @@ class Clean extends Command {
41
41
  }
42
42
  };
43
43
 
44
- async run(myt, opts) {
44
+ async _run(myt, ctx, cfg, opts) {
45
+ const {versionsDir} = ctx;
46
+
45
47
  const conn = await myt.dbConnect();
46
- const archiveDir = path.join(opts.versionsDir, '.archive');
48
+ const archiveDir = path.join(versionsDir, '.archive');
47
49
 
48
50
  const dbVersion = await myt.fetchDbVersion() || {};
49
51
  const number = parseInt(dbVersion.number);
50
52
 
51
53
  const oldVersions = [];
52
- const versionDirs = await fs.readdir(opts.versionsDir);
54
+ const versionDirs = await fs.readdir(versionsDir);
53
55
  for (const versionDir of versionDirs) {
54
56
  const version = await myt.loadVersion(versionDir);
55
57
  const shouldArchive = version
@@ -60,15 +62,17 @@ class Clean extends Command {
60
62
  oldVersions.push(versionDir);
61
63
  }
62
64
 
63
- if (opts.maxOldVersions
64
- && oldVersions.length > opts.maxOldVersions) {
65
- oldVersions.splice(-opts.maxOldVersions);
65
+ const {maxOldVersions} = cfg;
66
+
67
+ if (maxOldVersions
68
+ && oldVersions.length > maxOldVersions) {
69
+ oldVersions.splice(-maxOldVersions);
66
70
 
67
71
  if (!await fs.pathExists(archiveDir))
68
72
  await fs.mkdir(archiveDir);
69
73
 
70
74
  for (const oldVersion of oldVersions) {
71
- const srcDir = path.join(opts.versionsDir, oldVersion);
75
+ const srcDir = path.join(versionsDir, oldVersion);
72
76
  const dstDir = path.join(archiveDir, oldVersion);
73
77
 
74
78
  if (!await fs.pathExists(dstDir))
@@ -91,7 +95,7 @@ class Clean extends Command {
91
95
  this.emit('versionsArchived');
92
96
 
93
97
  if (opts.purge) {
94
- const versionDb = new VersionDb(myt, opts.versionsDir);
98
+ const versionDb = new VersionDb(myt, versionsDir);
95
99
  versionDb.load();
96
100
 
97
101
  const archiveDb = new VersionDb(myt, archiveDir);
@@ -101,7 +105,7 @@ class Clean extends Command {
101
105
  `SELECT number, file FROM versionLog
102
106
  WHERE code = ?
103
107
  ORDER BY number, file`,
104
- [opts.code]
108
+ [cfg.code]
105
109
  );
106
110
 
107
111
  let nPurged = 0;
@@ -113,7 +117,7 @@ class Clean extends Command {
113
117
  await conn.query(
114
118
  `DELETE FROM versionLog
115
119
  WHERE code = ? AND number = ? AND file = ?`,
116
- [opts.code, script.number, script.file]
120
+ [cfg.code, script.number, script.file]
117
121
  );
118
122
  nPurged++;
119
123
  }
package/myt-create.js CHANGED
@@ -13,7 +13,7 @@ class Create extends Command {
13
13
  operand: 'name'
14
14
  };
15
15
 
16
- static opts = {
16
+ static args = {
17
17
  alias: {
18
18
  type: 't',
19
19
  name: 'n'
@@ -27,12 +27,14 @@ class Create extends Command {
27
27
  }
28
28
  };
29
29
 
30
- async cli(myt, opts) {
31
- await super.cli(myt, opts);
30
+ async cli() {
31
+ await super.cli();
32
32
  console.log('Routine created.');
33
33
  }
34
34
 
35
- async run(myt, opts) {
35
+ async _run(myt, ctx, cfg, opts) {
36
+ const {type} = opts;
37
+
36
38
  const match = opts.name.match(/^(\w+)\.(\w+)$/);
37
39
  if (!match)
38
40
  throw new Error('Invalid object name, should contain schema and routine name');
@@ -43,30 +45,36 @@ class Create extends Command {
43
45
  const params = {
44
46
  schema,
45
47
  name,
46
- definer: opts.defaultDefiner
48
+ definer: cfg.defaultDefiner
47
49
  };
48
50
 
49
- switch (opts.type) {
51
+ switch (type) {
50
52
  case 'event':
51
- case 'function':
52
53
  case 'procedure':
53
54
  case 'trigger':
54
- params.body = "BEGIN\n-- Your code goes here\nEND";
55
+ params.body = "BEGIN\n\t-- Your code goes here\nEND";
56
+ break;
57
+ case 'function':
58
+ params.body = "BEGIN\n\tRETURN 1;\nEND";
55
59
  break;
56
60
  case 'view':
57
61
  params.definition = "SELECT TRUE"
58
62
  break;
59
63
  }
60
64
 
61
- const exporter = new Exporter(opts.type, opts.replace);
65
+ const exporter = new Exporter(type, cfg.replace);
62
66
  await exporter.init();
63
67
  const sql = exporter.format(params);
64
68
 
65
- const routineDir = `${opts.routinesDir}/${schema}/${opts.type}s`;
69
+ const routineDir = `${ctx.routinesDir}/${schema}/${type}s`;
66
70
  if (!await fs.pathExists(routineDir))
67
- await fs.mkdir(routineDir);
71
+ await fs.mkdir(routineDir, {recursive: true});
68
72
 
69
73
  const routineFile = `${routineDir}/${name}.sql`;
74
+
75
+ if (await fs.exists(routineFile))
76
+ throw new Error('Routine already exists');
77
+
70
78
  await fs.writeFile(routineFile, sql);
71
79
  }
72
80
  }