@jacebenson/jsn 4.3.7-dev.21 → 4.3.7-dev.23
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/package.json +1 -1
- package/src/commands/midservers.js +180 -22
- package/src/midserver-install.js +137 -0
package/package.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { input, password as passwordPrompt, select } from '@inquirer/prompts';
|
|
2
|
+
import { confirmDelete, formatRecordForDisplay, getStringField, interactiveList, canPrompt } from '../helpers.js';
|
|
3
|
+
import { FormatAuto } from '../output.js';
|
|
2
4
|
import { declareCapabilities } from '../capabilities.js';
|
|
3
5
|
import { resolveRecord, unwrapSysId } from '../resolve-record.js';
|
|
6
|
+
import { detectDocker, installDockerMidServer, validateMidCredentials } from '../midserver-install.js';
|
|
4
7
|
|
|
5
8
|
const AGENT_TABLE = 'ecc_agent';
|
|
6
9
|
const QUEUE_TABLE = 'ecc_queue';
|
|
@@ -87,21 +90,106 @@ async function resolveMidServer(sdk, identifier) {
|
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
async function listAgents(app, argv) {
|
|
93
|
+
const columns = argv.columns
|
|
94
|
+
? [...new Set(argv.columns.split(',').map(column => column.trim()).filter(Boolean))]
|
|
95
|
+
: LIST_FIELDS;
|
|
96
|
+
const query = argv.query || '';
|
|
97
|
+
|
|
98
|
+
if (canPrompt() && app.output.getFormat() === FormatAuto && !query) {
|
|
99
|
+
const picked = await interactiveList({
|
|
100
|
+
app,
|
|
101
|
+
table: AGENT_TABLE,
|
|
102
|
+
singular: 'MID Server',
|
|
103
|
+
columns,
|
|
104
|
+
limit: argv.limit,
|
|
105
|
+
labelField: 'name',
|
|
106
|
+
message: 'Select a MID Server',
|
|
107
|
+
formatLabel: (record) => {
|
|
108
|
+
const name = getStringField(record, 'name') || getStringField(record, 'sys_id');
|
|
109
|
+
const status = getStringField(record, 'status');
|
|
110
|
+
return status ? `${name} [${status}]` : name;
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
if (picked === undefined || picked === null) return;
|
|
114
|
+
picked._context = { instance_url: app.getEffectiveInstance(), table: AGENT_TABLE };
|
|
115
|
+
app.ok(picked, {
|
|
116
|
+
summary: `MID Server: ${getStringField(picked, 'name') || getStringField(picked, 'sys_id')}`,
|
|
117
|
+
breadcrumbs: [{ action: 'list', cmd: 'jsn midservers list', description: 'Back to all MID Servers' }],
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
90
122
|
const params = new URLSearchParams({
|
|
91
123
|
sysparm_limit: String(argv.limit),
|
|
92
124
|
sysparm_display_value: 'all',
|
|
93
|
-
sysparm_fields:
|
|
94
|
-
sysparm_query:
|
|
125
|
+
sysparm_fields: ['sys_id', ...columns.filter(column => column !== 'sys_id')].join(','),
|
|
126
|
+
sysparm_query: query ? `${query}^ORDERBYDESCsys_updated_on` : 'ORDERBYname',
|
|
95
127
|
});
|
|
96
128
|
const records = await app.sdk.list(AGENT_TABLE, params);
|
|
97
129
|
app.ok({
|
|
98
130
|
table: AGENT_TABLE,
|
|
99
131
|
count: records.length,
|
|
100
|
-
|
|
132
|
+
columns,
|
|
133
|
+
records: records.map(record => formatRecordForDisplay(record, columns)),
|
|
101
134
|
context: { instance_url: app.getEffectiveInstance() },
|
|
102
135
|
}, { summary: `${records.length} MID Server(s)` });
|
|
103
136
|
}
|
|
104
137
|
|
|
138
|
+
export function assertMidServerUp(record) {
|
|
139
|
+
const name = getStringField(record, 'name') || getStringField(record, 'sys_id') || 'MID Server';
|
|
140
|
+
const status = getStringField(record, 'status');
|
|
141
|
+
if (status.toLowerCase() !== 'up') {
|
|
142
|
+
const error = new Error(`MID Server '${name}' is not Up (status: ${status || 'unknown'}). Start or install it before executing a command.`);
|
|
143
|
+
error.code = 'midserver_not_up';
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function promptMidCredentials(app, argv) {
|
|
149
|
+
if (!canPrompt()) throw new Error('MID Server installation is interactive. Run it from a terminal.');
|
|
150
|
+
let username = argv.username || '';
|
|
151
|
+
while (true) {
|
|
152
|
+
username = username || (await input({
|
|
153
|
+
message: 'MID Server username:',
|
|
154
|
+
validate: value => value.trim() ? true : 'Username is required',
|
|
155
|
+
})).trim();
|
|
156
|
+
const password = await passwordPrompt({
|
|
157
|
+
message: 'MID Server password:',
|
|
158
|
+
mask: '*',
|
|
159
|
+
validate: value => value ? true : 'Password is required',
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
const validation = await validateMidCredentials(app.getEffectiveInstance(), username, password);
|
|
163
|
+
return { username, password, releaseName: validation.releaseName };
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const answer = await input({ message: `${error.message}. Press Enter to retry or Q to quit:` });
|
|
166
|
+
if (answer.trim().toLowerCase() === 'q') {
|
|
167
|
+
const quit = new Error('MID Server installation cancelled');
|
|
168
|
+
quit.code = 'cancelled';
|
|
169
|
+
throw quit;
|
|
170
|
+
}
|
|
171
|
+
username = argv.username || '';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function waitForMidServerUp(app, name, timeoutMs, intervalMs) {
|
|
177
|
+
const started = Date.now();
|
|
178
|
+
const fullName = `mid.server.${name.replace(/^mid\.server\./, '')}`;
|
|
179
|
+
while (Date.now() - started <= timeoutMs) {
|
|
180
|
+
const params = new URLSearchParams({
|
|
181
|
+
sysparm_query: `name=${fullName}`,
|
|
182
|
+
sysparm_limit: '1',
|
|
183
|
+
sysparm_display_value: 'all',
|
|
184
|
+
sysparm_fields: 'sys_id,name,status,host_name,version,last_heartbeat',
|
|
185
|
+
});
|
|
186
|
+
const records = await app.sdk.list(AGENT_TABLE, params);
|
|
187
|
+
if (records[0] && getStringField(records[0], 'status').toLowerCase() === 'up') return records[0];
|
|
188
|
+
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
|
189
|
+
}
|
|
190
|
+
throw new Error(`MID Server '${fullName}' did not report Up within ${timeoutMs}ms`);
|
|
191
|
+
}
|
|
192
|
+
|
|
105
193
|
export function midserversCmd(wrap) {
|
|
106
194
|
return {
|
|
107
195
|
command: 'midservers [subcommand]',
|
|
@@ -132,33 +220,102 @@ export function midserversCmd(wrap) {
|
|
|
132
220
|
})
|
|
133
221
|
.command({
|
|
134
222
|
command: 'add',
|
|
135
|
-
describe: '
|
|
223
|
+
describe: 'Install and verify a MID Server',
|
|
136
224
|
builder: (b) => b
|
|
137
|
-
.option('name', { type: 'string', describe: 'MID Server name (
|
|
138
|
-
.option('
|
|
139
|
-
.option('
|
|
140
|
-
.option('
|
|
141
|
-
.option('
|
|
225
|
+
.option('name', { type: 'string', describe: 'MID Server name (prompted when omitted)' })
|
|
226
|
+
.option('username', { type: 'string', describe: 'MID Server username (password is always prompted)' })
|
|
227
|
+
.option('install-method', { type: 'string', choices: ['docker', 'direct'], describe: 'Installation method (prompted when omitted)' })
|
|
228
|
+
.option('directory', { type: 'string', describe: 'Docker installation directory' })
|
|
229
|
+
.option('wait-timeout', { type: 'number', default: 120000, describe: 'How long to wait for the MID Server to report Up' })
|
|
230
|
+
.option('wait-interval', { type: 'number', default: 3000, describe: 'MID Server status polling interval in milliseconds' }),
|
|
142
231
|
handler: wrap(async (argv, app) => {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
232
|
+
app.requireInstance();
|
|
233
|
+
const credentials = await promptMidCredentials(app, argv);
|
|
234
|
+
const name = argv.name || (await input({
|
|
235
|
+
message: 'MID Server name:',
|
|
236
|
+
validate: value => value.trim() ? true : 'MID Server name is required',
|
|
237
|
+
})).trim();
|
|
238
|
+
const dockerStatus = detectDocker();
|
|
239
|
+
const dockerAvailable = dockerStatus.available;
|
|
240
|
+
const installMethod = argv['install-method'] || await select({
|
|
241
|
+
message: dockerAvailable
|
|
242
|
+
? 'Docker and Docker Compose are ready. How should the MID Server be installed?'
|
|
243
|
+
: dockerStatus.installed && dockerStatus.compose
|
|
244
|
+
? 'Docker is installed, but this user cannot access the Docker daemon. How should the MID Server be installed?'
|
|
245
|
+
: 'Docker was not detected. How should the MID Server be installed?',
|
|
246
|
+
choices: dockerAvailable
|
|
247
|
+
? [
|
|
248
|
+
{ name: 'Docker (recommended)', value: 'docker' },
|
|
249
|
+
{ name: 'Direct install (not available yet)', value: 'direct' },
|
|
250
|
+
]
|
|
251
|
+
: dockerStatus.installed && dockerStatus.compose
|
|
252
|
+
? [
|
|
253
|
+
{ name: 'Fix Docker daemon access first', value: 'docker-unavailable' },
|
|
254
|
+
{ name: 'Direct install (not available yet)', value: 'direct' },
|
|
255
|
+
]
|
|
256
|
+
: [
|
|
257
|
+
{ name: 'Install Docker first', value: 'install-docker' },
|
|
258
|
+
{ name: 'Direct install (not available yet)', value: 'direct' },
|
|
259
|
+
],
|
|
260
|
+
});
|
|
261
|
+
if (installMethod === 'direct') throw new Error('Direct MID Server installation is not implemented yet. Choose Docker.');
|
|
262
|
+
if (installMethod === 'docker-unavailable') throw new Error('Docker is installed, but this user cannot access the Docker daemon. Start Docker and grant this user access to /var/run/docker.sock, then run this command again.');
|
|
263
|
+
if (installMethod === 'install-docker') throw new Error('Automatic Docker installation is not implemented yet. Install Docker and Compose, then run this command again.');
|
|
264
|
+
|
|
265
|
+
const installation = await installDockerMidServer({
|
|
266
|
+
instanceURL: app.getEffectiveInstance(),
|
|
267
|
+
username: credentials.username,
|
|
268
|
+
password: credentials.password,
|
|
269
|
+
name,
|
|
270
|
+
releaseName: credentials.releaseName,
|
|
271
|
+
directory: argv.directory,
|
|
272
|
+
});
|
|
273
|
+
let record;
|
|
274
|
+
try {
|
|
275
|
+
record = await waitForMidServerUp(app, installation.name, Math.max(1, argv['wait-timeout']), Math.max(500, argv['wait-interval']));
|
|
276
|
+
} catch (error) {
|
|
277
|
+
try { installation.rollback(); } catch { /* preserve the verification error */ }
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
app.ok({
|
|
281
|
+
installation: 'docker',
|
|
282
|
+
directory: installation.installDir,
|
|
283
|
+
release: installation.releaseName,
|
|
284
|
+
record,
|
|
285
|
+
}, { summary: `Installed MID Server '${installation.name}' and verified it is Up` });
|
|
151
286
|
}),
|
|
152
287
|
})
|
|
153
288
|
.command({
|
|
154
|
-
command: 'remove
|
|
289
|
+
command: 'remove [identifier]',
|
|
155
290
|
aliases: ['delete'],
|
|
156
|
-
describe: 'Remove a MID Server record',
|
|
291
|
+
describe: 'Remove a MID Server record (interactive when no identifier is given)',
|
|
157
292
|
builder: (b) => b.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
158
293
|
handler: wrap(async (argv, app) => {
|
|
159
|
-
|
|
294
|
+
let record;
|
|
295
|
+
if (argv.identifier) {
|
|
296
|
+
record = await resolveMidServer(app.sdk, argv.identifier);
|
|
297
|
+
} else {
|
|
298
|
+
if (!canPrompt() || app.output.getFormat() !== FormatAuto) {
|
|
299
|
+
throw new Error('An identifier is required outside an interactive terminal');
|
|
300
|
+
}
|
|
301
|
+
record = await interactiveList({
|
|
302
|
+
app,
|
|
303
|
+
table: AGENT_TABLE,
|
|
304
|
+
singular: 'MID Server',
|
|
305
|
+
columns: LIST_FIELDS,
|
|
306
|
+
limit: 50,
|
|
307
|
+
labelField: 'name',
|
|
308
|
+
message: 'Select a MID Server to remove',
|
|
309
|
+
formatLabel: (candidate) => {
|
|
310
|
+
const name = getStringField(candidate, 'name') || getStringField(candidate, 'sys_id');
|
|
311
|
+
const status = getStringField(candidate, 'status');
|
|
312
|
+
return status ? `${name} [${status}]` : name;
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
if (!record) return;
|
|
316
|
+
}
|
|
160
317
|
const sysId = unwrapSysId(record);
|
|
161
|
-
const name = getStringField(record, 'name') || argv.identifier;
|
|
318
|
+
const name = getStringField(record, 'name') || argv.identifier || sysId;
|
|
162
319
|
await confirmDelete(app, argv, `Remove MID Server '${name}'`);
|
|
163
320
|
await app.sdk.delete(AGENT_TABLE, sysId);
|
|
164
321
|
app.ok({ name, sys_id: sysId, deleted: true }, { summary: `Removed MID Server '${name}'` });
|
|
@@ -173,6 +330,7 @@ export function midserversCmd(wrap) {
|
|
|
173
330
|
.option('interval', { type: 'number', default: DEFAULT_INTERVAL, describe: 'Polling interval in milliseconds' }),
|
|
174
331
|
handler: wrap(async (argv, app) => {
|
|
175
332
|
const record = await resolveMidServer(app.sdk, argv.identifier);
|
|
333
|
+
assertMidServerUp(record);
|
|
176
334
|
const agent = agentName(record);
|
|
177
335
|
const output = await app.sdk.create(QUEUE_TABLE, {
|
|
178
336
|
agent,
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const INSTALL_HOST = 'install.service-now.com';
|
|
8
|
+
|
|
9
|
+
function defaultRun(command, args, options = {}) {
|
|
10
|
+
return execFileSync(command, args, {
|
|
11
|
+
cwd: options.cwd,
|
|
12
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
stdio: options.stdio || 'inherit',
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertSafePart(value, label) {
|
|
19
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
|
|
20
|
+
throw new Error(`${label} may contain only letters, numbers, dots, underscores, and dashes`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeMidName(name) {
|
|
25
|
+
const shortName = String(name || '').replace(/^mid\.server\./, '');
|
|
26
|
+
assertSafePart(shortName, 'MID Server name');
|
|
27
|
+
return shortName;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildMidRecipeUrl(releaseName) {
|
|
31
|
+
const match = String(releaseName).match(/(\d{4})-(\d{2})-(\d{2})|(\d{2})-(\d{2})-(\d{4})/);
|
|
32
|
+
if (!match) throw new Error(`MID release name has no build date: ${releaseName}`);
|
|
33
|
+
const year = match[1] || match[6];
|
|
34
|
+
const month = match[2] || match[4];
|
|
35
|
+
const day = match[3] || match[5];
|
|
36
|
+
const filename = `mid-linux-container-recipe.${releaseName}.linux.x86-64.zip`;
|
|
37
|
+
return `https://${INSTALL_HOST}/glide/distribution/builds/package/app-signed/mid-linux-container-recipe/${year}/${month}/${day}/${filename}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function validateMidCredentials(instanceURL, username, password, fetchImpl = fetch) {
|
|
41
|
+
if (!username || !password) throw new Error('MID Server username and password are required');
|
|
42
|
+
const endpoint = `${String(instanceURL).replace(/\/$/, '')}/api/now/table/sys_properties?sysparm_query=name=mid.version&sysparm_fields=value&sysparm_limit=1`;
|
|
43
|
+
let response;
|
|
44
|
+
try {
|
|
45
|
+
response = await fetchImpl(endpoint, {
|
|
46
|
+
headers: {
|
|
47
|
+
Accept: 'application/json',
|
|
48
|
+
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
} catch {
|
|
52
|
+
throw new Error('Could not reach the ServiceNow instance to validate the MID Server credentials');
|
|
53
|
+
}
|
|
54
|
+
if (response.status === 401 || response.status === 403) {
|
|
55
|
+
throw new Error('The MID Server username or password was rejected by the ServiceNow instance');
|
|
56
|
+
}
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new Error(`MID Server credential validation failed with HTTP ${response.status}`);
|
|
59
|
+
}
|
|
60
|
+
const body = await response.json();
|
|
61
|
+
const releaseName = body?.result?.[0]?.value;
|
|
62
|
+
if (!releaseName) throw new Error('Credential validation succeeded, but the instance returned no mid.version value');
|
|
63
|
+
return { releaseName };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function detectDocker(run = defaultRun) {
|
|
67
|
+
const status = { installed: false, compose: false, daemon: false, available: false };
|
|
68
|
+
try { run('docker', ['--version'], { stdio: 'ignore' }); status.installed = true; } catch { return status; }
|
|
69
|
+
try { run('docker', ['compose', 'version'], { stdio: 'ignore' }); status.compose = true; } catch { return status; }
|
|
70
|
+
try { run('docker', ['info'], { stdio: 'ignore' }); status.daemon = true; } catch { return status; }
|
|
71
|
+
status.available = true;
|
|
72
|
+
return status;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function dockerComposeAvailable(run = defaultRun) {
|
|
76
|
+
return detectDocker(run).available;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function composeFile({ serviceName }) {
|
|
80
|
+
return `services:\n ${serviceName}:\n container_name: ${serviceName}\n image: ${serviceName}:\${MID_RELEASE}\n restart: unless-stopped\n volumes:\n - ./export:/opt/snc_mid_server/agent/export\n environment:\n MID_INSTANCE_URL: \${MID_INSTANCE_URL}\n MID_INSTANCE_USERNAME: \${MID_INSTANCE_USERNAME}\n MID_INSTANCE_PASSWORD: \${MID_INSTANCE_PASSWORD}\n MID_SERVER_NAME: \${MID_SERVER_NAME}\n`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function installDockerMidServer({
|
|
84
|
+
instanceURL,
|
|
85
|
+
username,
|
|
86
|
+
password,
|
|
87
|
+
name,
|
|
88
|
+
directory,
|
|
89
|
+
releaseName: suppliedReleaseName,
|
|
90
|
+
fetchImpl = fetch,
|
|
91
|
+
run = defaultRun,
|
|
92
|
+
}) {
|
|
93
|
+
if (!dockerComposeAvailable(run)) {
|
|
94
|
+
throw new Error('Docker and Docker Compose are required for Docker MID Server installation');
|
|
95
|
+
}
|
|
96
|
+
const shortName = normalizeMidName(name);
|
|
97
|
+
const displayName = `mid.server.${shortName}`;
|
|
98
|
+
const releaseName = suppliedReleaseName || (await validateMidCredentials(instanceURL, username, password, fetchImpl)).releaseName;
|
|
99
|
+
const recipeURL = buildMidRecipeUrl(releaseName);
|
|
100
|
+
const installDir = directory || path.join(os.homedir(), '.local', 'share', 'jsn', 'midservers', shortName);
|
|
101
|
+
fs.mkdirSync(installDir, { recursive: true, mode: 0o700 });
|
|
102
|
+
const exportDir = path.join(installDir, 'export');
|
|
103
|
+
fs.mkdirSync(exportDir, { recursive: true, mode: 0o700 });
|
|
104
|
+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsn-mid-'));
|
|
105
|
+
const filename = path.basename(recipeURL);
|
|
106
|
+
const archive = path.join(workDir, filename);
|
|
107
|
+
const recipeDir = path.join(workDir, 'recipe');
|
|
108
|
+
try {
|
|
109
|
+
run('curl', ['-fSL', '--connect-timeout', '10', '--max-time', '300', recipeURL, '-o', archive]);
|
|
110
|
+
fs.mkdirSync(recipeDir, { recursive: true, mode: 0o700 });
|
|
111
|
+
run('unzip', ['-q', archive, '-d', recipeDir]);
|
|
112
|
+
const composePath = path.join(installDir, 'docker-compose.yaml');
|
|
113
|
+
const serviceName = `jsn_mid_${shortName.replace(/[^A-Za-z0-9_.-]/g, '_')}`;
|
|
114
|
+
run('docker', ['build', '--tag', `${serviceName}:${releaseName}`, recipeDir]);
|
|
115
|
+
fs.writeFileSync(composePath, composeFile({ serviceName, instanceURL, displayName }), { mode: 0o600 });
|
|
116
|
+
const env = {
|
|
117
|
+
MID_RELEASE: releaseName,
|
|
118
|
+
MID_INSTANCE_URL: `${String(instanceURL).replace(/\/$/, '')}/`,
|
|
119
|
+
MID_INSTANCE_USERNAME: username,
|
|
120
|
+
MID_INSTANCE_PASSWORD: password,
|
|
121
|
+
MID_SERVER_NAME: displayName,
|
|
122
|
+
};
|
|
123
|
+
run('docker', ['compose', '-f', composePath, 'up', '-d'], { cwd: installDir, env });
|
|
124
|
+
return {
|
|
125
|
+
name: displayName,
|
|
126
|
+
releaseName,
|
|
127
|
+
installDir,
|
|
128
|
+
composePath,
|
|
129
|
+
serviceName,
|
|
130
|
+
rollback: () => run('docker', ['compose', '-f', composePath, 'down'], { cwd: installDir, env }),
|
|
131
|
+
};
|
|
132
|
+
} finally {
|
|
133
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export { defaultRun };
|