@nordicsemiconductor/pc-nrfconnect-shared 197.0.0 → 198.0.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.
@@ -6,10 +6,12 @@
6
6
  * SPDX-License-Identifier: LicenseRef-Nordic-4-Clause
7
7
  */
8
8
 
9
+ /* eslint-disable max-classes-per-file, class-methods-use-this */
10
+
9
11
  import { execSync } from 'child_process';
10
- import { program } from 'commander';
12
+ import { Option, program } from 'commander';
11
13
  import fs from 'fs';
12
- import FtpClient from 'ftp';
14
+ import LowlevelFtpClient from 'ftp';
13
15
  import semver from 'semver';
14
16
  import calculateShasum from 'shasum';
15
17
 
@@ -30,7 +32,218 @@ interface App {
30
32
  packageJson: PackageJsonApp;
31
33
  }
32
34
 
33
- const client = new FtpClient();
35
+ let client: Client;
36
+
37
+ abstract class Client {
38
+ abstract sourceUrl: string;
39
+
40
+ initialise(options: Options): Promise<void> | void {} // eslint-disable-line @typescript-eslint/no-unused-vars
41
+ end(): void {}
42
+
43
+ abstract download(filename: string): Promise<string>;
44
+ abstract uploadContent(
45
+ content: Buffer,
46
+ remoteFilename: string
47
+ ): Promise<void>;
48
+ abstract uploadLocalFile(
49
+ localFilename: string,
50
+ remoteFilename: string
51
+ ): Promise<void>;
52
+ }
53
+
54
+ class FtpClient extends Client {
55
+ /*
56
+ * To use this script with an FTP server REPO_HOST, REPO_USER and REPO_PASS need to be set
57
+ */
58
+
59
+ ftpClient = new LowlevelFtpClient();
60
+
61
+ host = process.env.REPO_HOST || 'localhost';
62
+ port = Number(process.env.REPO_PORT) || 21;
63
+ user = process.env.REPO_USER || 'anonymous';
64
+ password = process.env.REPO_PASS || 'anonymous@';
65
+
66
+ sourceDir: string;
67
+ sourceUrl: string;
68
+
69
+ constructor(private readonly options: Options) {
70
+ super();
71
+ this.sourceDir = this.getSourceDir();
72
+ this.sourceUrl = this.getSourceUrl();
73
+ }
74
+
75
+ getSourceDir = () => {
76
+ const repoDirOfficial = '/.pc-tools/nrfconnect-apps';
77
+
78
+ if (process.env.REPO_DIR) return process.env.REPO_DIR;
79
+ if (this.options.deployOfficial) return repoDirOfficial;
80
+
81
+ return `${repoDirOfficial}/${this.options.source}`;
82
+ };
83
+
84
+ getSourceUrl = () => {
85
+ if (process.env.REPO_URL) return process.env.REPO_URL;
86
+ return `https://developer.nordicsemi.com${this.sourceDir}`;
87
+ };
88
+
89
+ connect = () =>
90
+ new Promise<void>((resolve, reject) => {
91
+ console.log(
92
+ `Connecting to ftp://${this.user}@${this.host}:${this.port}`
93
+ );
94
+ this.ftpClient.once('error', err => {
95
+ this.ftpClient.removeAllListeners('ready');
96
+ reject(err);
97
+ });
98
+ this.ftpClient.once('ready', () => {
99
+ this.ftpClient.removeAllListeners('error');
100
+ resolve();
101
+ });
102
+ this.ftpClient.connect({
103
+ host: this.host,
104
+ port: this.port,
105
+ user: this.user,
106
+ password: this.password,
107
+ });
108
+ });
109
+
110
+ createSourceDirectory = () =>
111
+ new Promise<void>((resolve, reject) => {
112
+ console.log(`Creating source directory ${this.sourceDir}`);
113
+ this.ftpClient.mkdir(this.sourceDir, true, err => {
114
+ if (err) {
115
+ reject(new Error(`Failed to create source directory.`));
116
+ } else {
117
+ resolve();
118
+ }
119
+ });
120
+ });
121
+
122
+ changeWorkingDirectory = () =>
123
+ new Promise<void>((resolve, reject) => {
124
+ console.log(`Changing to directory ${this.sourceDir}`);
125
+ this.ftpClient.cwd(this.sourceDir, err => {
126
+ if (err) {
127
+ reject(
128
+ new Error(
129
+ '\nError: Failed to change to directory. ' +
130
+ 'Check whether it exists on the FTP server.\n' +
131
+ 'If you want to create a new source, use the ' +
132
+ '--create-source option.'
133
+ )
134
+ );
135
+ } else {
136
+ resolve();
137
+ }
138
+ });
139
+ });
140
+
141
+ initialise = async () => {
142
+ await this.connect();
143
+ if (this.options.doCreateSource) {
144
+ await this.createSourceDirectory();
145
+ }
146
+ await this.changeWorkingDirectory();
147
+ };
148
+
149
+ download = (filename: string) =>
150
+ new Promise<string>((resolve, reject) => {
151
+ console.log(`Downloading file ${filename}`);
152
+ let data = '';
153
+ this.ftpClient.get(filename, (err, stream) => {
154
+ if (err) return reject(err);
155
+ stream.once('close', () => resolve(data));
156
+ stream.on('data', chunk => {
157
+ data += chunk;
158
+ });
159
+ return undefined;
160
+ });
161
+ });
162
+
163
+ upload = (
164
+ contentOrLocalFilename: string | Buffer,
165
+ remoteFilename: string
166
+ ) =>
167
+ new Promise<void>((resolve, reject) => {
168
+ console.log(`Uploading file ${remoteFilename}`);
169
+ this.ftpClient.put(contentOrLocalFilename, remoteFilename, err =>
170
+ err ? reject(err) : resolve()
171
+ );
172
+ });
173
+
174
+ uploadContent = this.upload;
175
+ uploadLocalFile = this.upload;
176
+
177
+ end = () => this.ftpClient.end();
178
+ }
179
+
180
+ class ArtifactoryClient extends Client {
181
+ token = process.env.ARTIFACTORY_TOKEN;
182
+
183
+ sourceUrl: string;
184
+
185
+ constructor(private readonly options: Options) {
186
+ super();
187
+
188
+ if (this.token == null) {
189
+ throw new Error(
190
+ 'The environment variable ARTIFACTORY_TOKEN must be set.'
191
+ );
192
+ }
193
+
194
+ this.sourceUrl = `https://files.nordicsemi.com/artifactory/swtools/${this.getAccessLevel()}/ncd/apps/${
195
+ options.source
196
+ }`;
197
+ }
198
+
199
+ getAccessLevel = () => {
200
+ if (this.options.accessLevel != null) return this.options.accessLevel;
201
+
202
+ return this.options.deployOfficial ? 'external' : 'internal';
203
+ };
204
+
205
+ download = async (filename: string) => {
206
+ console.log(`Downloading ${filename}`);
207
+
208
+ const url = `${this.sourceUrl}/${filename}`;
209
+ const res = await fetch(url, {
210
+ headers: { Authorization: `Bearer ${this.token}` },
211
+ });
212
+
213
+ if (!res.ok) {
214
+ throw new Error(`Failed to download ${url}: ${res.statusText}`);
215
+ }
216
+
217
+ return res.text();
218
+ };
219
+
220
+ upload = async (content: Buffer, remoteFilename: string) => {
221
+ const url = `${this.sourceUrl}/${remoteFilename}`;
222
+ const res = await fetch(url, {
223
+ method: 'PUT',
224
+ body: content,
225
+ headers: { Authorization: `Bearer ${this.token}` },
226
+ });
227
+
228
+ if (!res.ok) {
229
+ throw new Error(`Failed to upload ${url}: ${res.statusText}`);
230
+ }
231
+ };
232
+
233
+ uploadContent = (content: Buffer, remoteFilename: string) => {
234
+ console.log(`Uploading content for ${remoteFilename}`);
235
+
236
+ return this.upload(content, remoteFilename);
237
+ };
238
+
239
+ uploadLocalFile = (localFilename: string, remoteFilename: string) => {
240
+ console.log(
241
+ `Uploading local file ${localFilename} as ${remoteFilename}`
242
+ );
243
+
244
+ return this.upload(fs.readFileSync(localFilename), remoteFilename);
245
+ };
246
+ }
34
247
 
35
248
  const hasMessage = (error: unknown): error is { message: unknown } =>
36
249
  error != null && typeof error === 'object' && 'message' in error;
@@ -38,40 +251,66 @@ const hasMessage = (error: unknown): error is { message: unknown } =>
38
251
  const errorAsString = (error: unknown) =>
39
252
  hasMessage(error) ? error.message : String(error);
40
253
 
41
- const getSourceDir = (deployOfficial: boolean, sourceName: string) => {
42
- const repoDirOfficial = '/.pc-tools/nrfconnect-apps';
254
+ const validAccessLevels = [
255
+ 'external',
256
+ 'external-confidential',
257
+ 'internal',
258
+ 'internal-confidential',
259
+ ] as const;
43
260
 
44
- if (process.env.REPO_DIR) return process.env.REPO_DIR;
45
- if (deployOfficial) return repoDirOfficial;
261
+ type AccessLevel = (typeof validAccessLevels)[number];
46
262
 
47
- return `${repoDirOfficial}/${sourceName}`;
48
- };
263
+ const isAccessLevel = (value: string): value is AccessLevel =>
264
+ (validAccessLevels as readonly string[]).includes(value);
265
+
266
+ const splitSourceAndAccessLevel = (sourceAndMaybeAccessLevel: string) => {
267
+ const match = sourceAndMaybeAccessLevel.match(
268
+ /(?<source>.*?)\s*\((?<accessLevel>.*)\)/
269
+ );
270
+
271
+ if (match == null) {
272
+ return { source: sourceAndMaybeAccessLevel };
273
+ }
49
274
 
50
- const getSourceUrl = (deployOfficial: boolean, sourceName: string) => {
51
- const repoUrlOfficial =
52
- 'https://developer.nordicsemi.com/.pc-tools/nrfconnect-apps';
275
+ const { source, accessLevel } = match.groups!; // eslint-disable-line @typescript-eslint/no-non-null-assertion -- Can never be null because of the regex
53
276
 
54
- if (process.env.REPO_URL) return process.env.REPO_URL;
55
- if (deployOfficial) return repoUrlOfficial;
277
+ if (!isAccessLevel(accessLevel)) {
278
+ throw new Error(
279
+ `The specified access level "${accessLevel}" must be one of ${validAccessLevels.join(
280
+ ', '
281
+ )}.`
282
+ );
283
+ }
56
284
 
57
- return `${repoUrlOfficial}/${sourceName}`;
285
+ return { source, accessLevel };
58
286
  };
59
287
 
60
288
  interface Options {
61
289
  doPack: boolean;
62
290
  doCreateSource: boolean;
63
291
  deployOfficial: boolean;
64
- sourceDir: string;
65
- sourceUrl: string;
292
+ source: string;
66
293
  sourceName?: string;
294
+ destination: 'ftp' | 'artifactory';
295
+ accessLevel?: AccessLevel;
67
296
  }
68
297
 
69
298
  const parseOptions = (): Options => {
70
299
  program
71
- .description('Publish to nordic repository')
300
+ .description('Publish an nRF Connect for Desktop app')
72
301
  .requiredOption(
73
302
  '-s, --source <source>',
74
- 'Specify the source to publish (e.g. official).'
303
+ 'Specify the source to publish (e.g. "official" or "releast-test"). ' +
304
+ 'When publishing to Artifactory, an access level can be ' +
305
+ 'specified at the end in parantheses (e.g. "official (external)").'
306
+ )
307
+ .addOption(
308
+ new Option(
309
+ '-d, --destination <ftp|artifactory>',
310
+ 'Specify where to publish.'
311
+ )
312
+ .choices(['ftp', 'artifactory'])
313
+ .makeOptionMandatory()
75
314
  )
76
315
  .option(
77
316
  '-n, --no-pack',
@@ -87,15 +326,18 @@ const parseOptions = (): Options => {
87
326
 
88
327
  const options = program.opts();
89
328
 
90
- const deployOfficial = options.source === 'official';
329
+ const { source, accessLevel } = splitSourceAndAccessLevel(options.source);
330
+
331
+ const deployOfficial = source === 'official';
91
332
 
92
333
  return {
93
334
  doPack: options.pack,
94
335
  doCreateSource: options.createSource != null,
336
+ source,
95
337
  sourceName: options.createSource,
96
338
  deployOfficial,
97
- sourceDir: getSourceDir(deployOfficial, options.source),
98
- sourceUrl: getSourceUrl(deployOfficial, options.source),
339
+ destination: options.destination,
340
+ accessLevel,
99
341
  };
100
342
  };
101
343
 
@@ -144,7 +386,7 @@ const packOrReadPackage = (options: Options): App => {
144
386
  version,
145
387
  filename,
146
388
  shasum,
147
- sourceUrl: options.sourceUrl,
389
+ sourceUrl: client.sourceUrl,
148
390
  isOfficial: options.deployOfficial,
149
391
  appInfoName: `${name}.json`,
150
392
  releaseNotesFilename: `${name}-Changelog.md`,
@@ -153,72 +395,6 @@ const packOrReadPackage = (options: Options): App => {
153
395
  };
154
396
  };
155
397
 
156
- const connect = (config: {
157
- host: string;
158
- port: number;
159
- user: string;
160
- password: string;
161
- }) =>
162
- new Promise<void>((resolve, reject) => {
163
- console.log(
164
- `Connecting to ftp://${config.user}@${config.host}:${config.port}`
165
- );
166
- client.once('error', err => {
167
- client.removeAllListeners('ready');
168
- reject(err);
169
- });
170
- client.once('ready', () => {
171
- client.removeAllListeners('error');
172
- resolve();
173
- });
174
- client.connect(config);
175
- });
176
-
177
- const createSourceDirectory = (dir: string) =>
178
- new Promise<void>((resolve, reject) => {
179
- console.log(`Creating source directory ${dir}`);
180
- client.mkdir(dir, true, err => {
181
- if (err) {
182
- reject(new Error(`Failed to create source directory.`));
183
- } else {
184
- resolve();
185
- }
186
- });
187
- });
188
-
189
- const changeWorkingDirectory = (dir: string) =>
190
- new Promise<void>((resolve, reject) => {
191
- console.log(`Changing to directory ${dir}`);
192
- client.cwd(dir, err => {
193
- if (err) {
194
- reject(
195
- new Error(
196
- '\nError: Failed to change to directory. ' +
197
- 'Check whether it exists on the FTP server.\n' +
198
- 'If you want to create a new source, use the ' +
199
- '--create-source option.'
200
- )
201
- );
202
- } else {
203
- resolve();
204
- }
205
- });
206
- });
207
-
208
- const downloadFileContent = (filename: string) =>
209
- new Promise<string>((resolve, reject) => {
210
- console.log(`Downloading file ${filename}`);
211
- let data = '';
212
- client.get(filename, (err, stream) => {
213
- if (err) return reject(err);
214
- stream.once('close', () => resolve(data));
215
- stream.on('data', chunk => {
216
- data += chunk;
217
- });
218
- return undefined;
219
- });
220
- });
221
-
222
398
  const assertAppVersionIsValid = (
223
399
  latestAppVersion: string | undefined,
224
400
  app: App
@@ -234,21 +410,9 @@ const assertAppVersionIsValid = (
234
410
  }
235
411
  };
236
412
 
237
- type UploadLocalFile = (localFileName: string, remote: string) => Promise<void>;
238
- type UploadBufferContent = (content: Buffer, remote: string) => Promise<void>;
239
-
240
- const uploadFile: UploadLocalFile & UploadBufferContent = (
241
- local: string | Buffer,
242
- remote: string
243
- ) =>
244
- new Promise<void>((resolve, reject) => {
245
- console.log(`Uploading file ${remote}`);
246
- client.put(local, remote, err => (err ? reject(err) : resolve()));
247
- });
248
-
249
413
  const createBlankSourceJson = async (name: string) => {
250
414
  try {
251
- await downloadFileContent('source.json');
415
+ await client.download('source.json');
252
416
  } catch {
253
417
  // Expected that the download throws an exception,
254
418
  // because the file is supposed to not exist yet
@@ -266,7 +430,7 @@ const createBlankSourceJson = async (name: string) => {
266
430
  const downloadSourceJson = async () => {
267
431
  let sourceJsonContent;
268
432
  try {
269
- sourceJsonContent = await downloadFileContent('source.json');
433
+ sourceJsonContent = await client.download('source.json');
270
434
  const sourceJson = <SourceJson>JSON.parse(sourceJsonContent);
271
435
  if (
272
436
  sourceJson == null ||
@@ -281,7 +445,8 @@ const downloadSourceJson = async () => {
281
445
 
282
446
  return sourceJson;
283
447
  } catch (error) {
284
- const message = 'Unable to read `source.json` on the server.\nError: ';
448
+ const message =
449
+ 'Unable to read `source.json` on the server. If you want to create a new source, use the option --create-source.\nError: ';
285
450
  const caughtError = errorAsString(error);
286
451
  const maybeSourceJsonContent =
287
452
  sourceJsonContent == null
@@ -313,7 +478,7 @@ const downloadExistingAppInfo = async (
313
478
  app: App
314
479
  ): Promise<Partial<Pick<AppInfo, 'latestVersion' | 'versions'>>> => {
315
480
  try {
316
- const appInfoContent = await downloadFileContent(app.appInfoName);
481
+ const appInfoContent = await client.download(app.appInfoName);
317
482
  return JSON.parse(appInfoContent) as AppInfo;
318
483
  } catch (error) {
319
484
  console.log(
@@ -330,8 +495,13 @@ const failBecauseOfMissingProperty = () => {
330
495
  );
331
496
  };
332
497
 
333
- const getUpdatedAppInfo = async (app: App): Promise<AppInfo> => {
334
- const oldAppInfo = await downloadExistingAppInfo(app);
498
+ const getUpdatedAppInfo = async (
499
+ app: App,
500
+ options: Options
501
+ ): Promise<AppInfo> => {
502
+ const oldAppInfo = options.doCreateSource
503
+ ? {}
504
+ : await downloadExistingAppInfo(app);
335
505
 
336
506
  assertAppVersionIsValid(oldAppInfo.latestVersion, app);
337
507
 
@@ -367,18 +537,19 @@ const getUpdatedAppInfo = async (app: App): Promise<AppInfo> => {
367
537
  };
368
538
 
369
539
  const uploadSourceJson = (sourceJson: SourceJson) =>
370
- uploadFile(
540
+ client.uploadContent(
371
541
  Buffer.from(JSON.stringify(sourceJson, undefined, 2)),
372
542
  'source.json'
373
543
  );
374
544
 
375
545
  const uploadAppInfo = (app: App, appInfo: AppInfo) =>
376
- uploadFile(
546
+ client.uploadContent(
377
547
  Buffer.from(JSON.stringify(appInfo, undefined, 2)),
378
548
  app.appInfoName
379
549
  );
380
550
 
381
- const uploadPackage = (app: App) => uploadFile(app.filename, app.filename);
551
+ const uploadPackage = (app: App) =>
552
+ client.uploadLocalFile(app.filename, app.filename);
382
553
 
383
554
  const uploadChangelog = (app: App) => {
384
555
  const changelogFilename = 'Changelog.md';
@@ -387,7 +558,7 @@ const uploadChangelog = (app: App) => {
387
558
  return Promise.reject(new Error(errorMsg));
388
559
  }
389
560
 
390
- return uploadFile(changelogFilename, app.releaseNotesFilename);
561
+ return client.uploadLocalFile(changelogFilename, app.releaseNotesFilename);
391
562
  };
392
563
 
393
564
  const uploadIcon = (app: App) => {
@@ -397,37 +568,28 @@ const uploadIcon = (app: App) => {
397
568
  return Promise.reject(new Error(errorMsg));
398
569
  }
399
570
 
400
- return uploadFile(localIconFilename, app.iconFilename);
571
+ return client.uploadLocalFile(localIconFilename, app.iconFilename);
401
572
  };
402
573
 
403
574
  const main = async () => {
404
575
  try {
405
- /*
406
- * To use this script REPO_HOST, REPO_USER and REPO_PASS will need to be set
407
- */
408
- const config = {
409
- host: process.env.REPO_HOST || 'localhost',
410
- port: Number(process.env.REPO_PORT) || 21,
411
- user: process.env.REPO_USER || 'anonymous',
412
- password: process.env.REPO_PASS || 'anonymous@',
413
- };
414
-
415
576
  const options = parseOptions();
416
577
 
578
+ client =
579
+ options.destination === 'ftp'
580
+ ? new FtpClient(options)
581
+ : new ArtifactoryClient(options);
582
+
417
583
  checkAppProperties({
418
584
  checkChangelogHasCurrentEntry: options.deployOfficial,
419
585
  });
420
586
 
421
587
  const app = packOrReadPackage(options);
422
588
 
423
- await connect(config);
424
- if (options.doCreateSource) {
425
- await createSourceDirectory(options.sourceDir);
426
- }
427
- await changeWorkingDirectory(options.sourceDir);
589
+ await client.initialise(options);
428
590
 
429
591
  const sourceJson = await getUpdatedSourceJson(app, options);
430
- const appInfo = await getUpdatedAppInfo(app);
592
+ const appInfo = await getUpdatedAppInfo(app, options);
431
593
 
432
594
  await uploadChangelog(app);
433
595
  await uploadIcon(app);
@@ -441,7 +603,7 @@ const main = async () => {
441
603
  process.exitCode = 1;
442
604
  }
443
605
 
444
- client.end();
606
+ client?.end();
445
607
  };
446
608
 
447
609
  main();
@@ -78,8 +78,8 @@ const updateChangelog = (nextReleaseNumber: string) => {
78
78
  }
79
79
 
80
80
  if (
81
- header === 'Unreleased' ||
82
- header === `${nextReleaseNumber} - Unreleased`
81
+ header.toLowerCase() === 'unreleased' ||
82
+ header.toLowerCase() === `${nextReleaseNumber} - unreleased`
83
83
  ) {
84
84
  writeFileSync(
85
85
  'Changelog.md',
@@ -8,11 +8,7 @@ import React from 'react';
8
8
  import { useSelector } from 'react-redux';
9
9
 
10
10
  import Card from '../Card/Card';
11
- import {
12
- buyOnlineUrl,
13
- deviceInfo,
14
- productPageUrl,
15
- } from '../Device/deviceInfo/deviceInfo';
11
+ import { deviceInfo } from '../Device/deviceInfo/deviceInfo';
16
12
  import { selectedDevice, selectedDeviceInfo } from '../Device/deviceSlice';
17
13
  import AboutButton from './AboutButton';
18
14
  import Section from './Section';
@@ -38,7 +34,7 @@ export default () => {
38
34
  }
39
35
 
40
36
  const pca = device.devkit?.boardVersion;
41
- const { name, cores } = deviceInfo(device);
37
+ const { name, cores, website } = deviceInfo(device);
42
38
 
43
39
  return (
44
40
  <Card title="Device">
@@ -61,13 +57,13 @@ export default () => {
61
57
 
62
58
  <Section>
63
59
  <AboutButton
64
- url={buyOnlineUrl(device)}
60
+ url={website.buyOnline}
65
61
  label="Find distributor"
66
62
  />
67
63
  </Section>
68
64
  <Section>
69
65
  <AboutButton
70
- url={productPageUrl(device)}
66
+ url={website.productPage}
71
67
  label="Go to product page"
72
68
  />
73
69
  </Section>