@noforeignland/signalk-to-noforeignland 1.0.1-beta.8 → 1.1.0-beta.11

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.
@@ -0,0 +1,306 @@
1
+ const { EOL } = require('os');
2
+ const fs = require('fs-extra');
3
+ const path = require('path');
4
+
5
+ class TrackLogger {
6
+ constructor(app, options, trackDir) {
7
+ this.app = app;
8
+ this.options = options;
9
+ this.trackDir = trackDir;
10
+ this.routeSaveName = 'pending.jsonl';
11
+
12
+ this.lastPosition = null;
13
+ this.lastPositionReceived = null;
14
+ this.autoSelectedSource = null;
15
+ this.unsubscribes = [];
16
+ }
17
+
18
+ /**
19
+ * Start logging position data
20
+ */
21
+ startLogging(onSavePoint) {
22
+ let shouldDoLog = true;
23
+
24
+ // Subscribe to position updates
25
+ this.app.subscriptionmanager.subscribe({
26
+ context: 'vessels.self',
27
+ subscribe: [{
28
+ path: 'navigation.position',
29
+ format: 'delta',
30
+ policy: 'instant',
31
+ minPeriod: this.options.trackFrequency ? this.options.trackFrequency * 1000 : 0
32
+ }]
33
+ }, this.unsubscribes,
34
+ (subscriptionError) => {
35
+ this.app.debug('Error subscription to data:' + subscriptionError);
36
+ throw new Error('Error subscription to data:' + subscriptionError.message);
37
+ },
38
+ this.doOnValue.bind(this, () => shouldDoLog, newShould => { shouldDoLog = newShould; }, onSavePoint));
39
+
40
+ // Subscribe for speed if minSpeed is configured
41
+ if (this.options.minSpeed) {
42
+ this.subscribeToSpeed(() => shouldDoLog, newShould => { shouldDoLog = newShould; });
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Subscribe to speed over ground
48
+ */
49
+ subscribeToSpeed(getShouldDoLog, setShouldDoLog) {
50
+ this.app.subscriptionmanager.subscribe({
51
+ context: 'vessels.self',
52
+ subscribe: [{
53
+ path: 'navigation.speedOverGround',
54
+ format: 'delta',
55
+ policy: 'instant'
56
+ }]
57
+ }, this.unsubscribes,
58
+ (subscriptionError) => {
59
+ this.app.debug('Error subscription to data:' + subscriptionError);
60
+ throw new Error('Error subscription to data:' + subscriptionError.message);
61
+ },
62
+ (delta) => {
63
+ delta.updates.forEach(update => {
64
+ if (this.options.filterSource && update.$source !== this.options.filterSource) {
65
+ return;
66
+ }
67
+ update.values.forEach(value => {
68
+ const speedInKnots = value.value * 1.94384;
69
+ if (!getShouldDoLog() && this.options.minSpeed < speedInKnots) {
70
+ this.app.debug('setting shouldDoLog to true, speed:', speedInKnots.toFixed(2), 'knots');
71
+ setShouldDoLog(true);
72
+ }
73
+ });
74
+ });
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Handle incoming position values
80
+ */
81
+ async doOnValue(getShouldDoLog, setShouldDoLog, onSavePoint, delta) {
82
+ for (const update of delta.updates) {
83
+ // Handle source selection (auto or filtered)
84
+ if (!this.handleSourceSelection(update)) {
85
+ continue;
86
+ }
87
+
88
+ const timestamp = update.timestamp;
89
+
90
+ for (const value of update.values) {
91
+ // Validate position
92
+ if (!this.isValidPosition(value.value)) {
93
+ continue;
94
+ }
95
+
96
+ // Check if we should save (24h ping or shouldDoLog)
97
+ const force24hSave = this.should24hPing();
98
+ if (!force24hSave && !getShouldDoLog()) {
99
+ this.app.debug('shouldDoLog is false, not logging position');
100
+ continue;
101
+ }
102
+
103
+ // Check timestamp and distance
104
+ if (this.lastPosition && !force24hSave) {
105
+ if (!this.shouldLogPosition(timestamp, value.value)) {
106
+ continue;
107
+ }
108
+ }
109
+
110
+ // Save point
111
+ this.app.debug('Saving position from source:', update.$source,
112
+ 'lat:', value.value.latitude, 'lon:', value.value.longitude);
113
+
114
+ this.lastPosition = {
115
+ pos: value.value,
116
+ timestamp,
117
+ currentTime: new Date().getTime()
118
+ };
119
+
120
+ await this.savePoint(this.lastPosition);
121
+ onSavePoint(this.lastPosition);
122
+
123
+ // Reset shouldDoLog if minSpeed is active
124
+ if (this.options.minSpeed) {
125
+ this.app.debug('options.minSpeed - setting shouldDoLog to false');
126
+ setShouldDoLog(false);
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Handle GNSS source selection (auto-select or filtered)
134
+ */
135
+ handleSourceSelection(update) {
136
+ if (!this.options.filterSource) {
137
+ // Auto-select logic
138
+ const timeSinceLastPosition = this.lastPositionReceived
139
+ ? (new Date().getTime() - this.lastPositionReceived) / 1000
140
+ : null;
141
+
142
+ if (!this.autoSelectedSource) {
143
+ this.autoSelectedSource = update.$source;
144
+ this.lastPositionReceived = new Date().getTime();
145
+ this.app.debug(`Auto-selected GNSS source: '${this.autoSelectedSource}'`);
146
+ } else if (update.$source !== this.autoSelectedSource) {
147
+ if (timeSinceLastPosition && timeSinceLastPosition > 300) {
148
+ this.app.debug(`Switching from stale source '${this.autoSelectedSource}' to '${update.$source}' (no data for ${timeSinceLastPosition.toFixed(0)}s)`);
149
+ this.autoSelectedSource = update.$source;
150
+ this.lastPositionReceived = new Date().getTime();
151
+ } else {
152
+ this.app.debug(`Ignoring position from '${update.$source}', using auto-selected source '${this.autoSelectedSource}'`);
153
+ return false;
154
+ }
155
+ } else {
156
+ this.lastPositionReceived = new Date().getTime();
157
+ }
158
+ } else if (update.$source !== this.options.filterSource) {
159
+ // Filtered source
160
+ this.app.debug(`Ignoring position from '${update.$source}', filterSource is set to '${this.options.filterSource}'`);
161
+ return false;
162
+ } else {
163
+ this.lastPositionReceived = new Date().getTime();
164
+ }
165
+
166
+ return true;
167
+ }
168
+
169
+ /**
170
+ * Validate position (lat/lon and not at 0,0)
171
+ */
172
+ isValidPosition(position) {
173
+ // Check if near (0,0) - likely invalid
174
+ if (Math.abs(position.latitude) <= 0.01 && Math.abs(position.longitude) <= 0.01) {
175
+ this.app.debug('GNSS coordinates near (0,0), ignoring point to avoid invalid data logging.');
176
+ return false;
177
+ }
178
+
179
+ // Validate lat/lon ranges
180
+ if (!this.isValidLatitude(position.latitude) || !this.isValidLongitude(position.longitude)) {
181
+ this.app.debug('got invalid position, ignoring...', position);
182
+ return false;
183
+ }
184
+
185
+ return true;
186
+ }
187
+
188
+ /**
189
+ * Check if 24h ping should force a save
190
+ */
191
+ should24hPing() {
192
+ if (this.options.ping_api_every_24h && this.lastPosition) {
193
+ const timeSinceLastPoint = (new Date().getTime() - this.lastPosition.currentTime);
194
+ if (timeSinceLastPoint >= 24 * 60 * 60 * 1000) {
195
+ this.app.debug('24h since last point, forcing save of point to keep boat active on NFL');
196
+ return true;
197
+ }
198
+ }
199
+ return false;
200
+ }
201
+
202
+ /**
203
+ * Check if position should be logged based on timestamp and distance
204
+ */
205
+ shouldLogPosition(timestamp, position) {
206
+ // Check timestamp
207
+ if (new Date(this.lastPosition.timestamp).getTime() > new Date(timestamp).getTime()) {
208
+ this.app.debug('got error in timestamp:', timestamp, 'is earlier than previous:', this.lastPosition.timestamp);
209
+ return false;
210
+ }
211
+
212
+ // Check distance
213
+ const distance = this.equirectangularDistance(this.lastPosition.pos, position);
214
+ if (this.options.minMove && distance < this.options.minMove) {
215
+ this.app.debug('Distance', distance.toFixed(2), 'm is less than minMove', this.options.minMove, 'm - skipping');
216
+ return false;
217
+ }
218
+
219
+ this.app.debug('Distance', distance.toFixed(2), 'm is greater than minMove', this.options.minMove, 'm - logging');
220
+ return true;
221
+ }
222
+
223
+ /**
224
+ * Save position point to file
225
+ */
226
+ async savePoint(point) {
227
+ const obj = {
228
+ lat: point.pos.latitude,
229
+ lon: point.pos.longitude,
230
+ t: point.timestamp
231
+ };
232
+ this.app.debug(`save data point:`, obj);
233
+ await fs.appendFile(
234
+ path.join(this.trackDir, this.routeSaveName),
235
+ JSON.stringify(obj) + EOL
236
+ );
237
+ }
238
+
239
+ /**
240
+ * Calculate distance between two positions
241
+ */
242
+ equirectangularDistance(from, to) {
243
+ const rad = Math.PI / 180;
244
+ const φ1 = from.latitude * rad;
245
+ const φ2 = to.latitude * rad;
246
+ const Δλ = (to.longitude - from.longitude) * rad;
247
+ const R = 6371e3;
248
+ const x = Δλ * Math.cos((φ1 + φ2) / 2);
249
+ const y = (φ2 - φ1);
250
+ const d = Math.sqrt(x * x + y * y) * R;
251
+ return d;
252
+ }
253
+
254
+ /**
255
+ * Validate latitude
256
+ */
257
+ isValidLatitude(obj) {
258
+ return this.isDefinedNumber(obj) && obj > -90 && obj < 90;
259
+ }
260
+
261
+ /**
262
+ * Validate longitude
263
+ */
264
+ isValidLongitude(obj) {
265
+ return this.isDefinedNumber(obj) && obj > -180 && obj < 180;
266
+ }
267
+
268
+ /**
269
+ * Check if value is a defined number
270
+ */
271
+ isDefinedNumber(obj) {
272
+ return (obj !== undefined && obj !== null && typeof obj === 'number');
273
+ }
274
+
275
+ /**
276
+ * Stop logging and unsubscribe
277
+ */
278
+ stop() {
279
+ this.unsubscribes.forEach(f => f());
280
+ this.unsubscribes = [];
281
+ this.autoSelectedSource = null;
282
+ }
283
+
284
+ /**
285
+ * Get last position
286
+ */
287
+ getLastPosition() {
288
+ return this.lastPosition;
289
+ }
290
+
291
+ /**
292
+ * Get last position received time
293
+ */
294
+ getLastPositionReceived() {
295
+ return this.lastPositionReceived;
296
+ }
297
+
298
+ /**
299
+ * Get auto-selected source
300
+ */
301
+ getAutoSelectedSource() {
302
+ return this.autoSelectedSource;
303
+ }
304
+ }
305
+
306
+ module.exports = TrackLogger;
@@ -0,0 +1,110 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+
4
+ class TrackMigration {
5
+ constructor(app, trackDir) {
6
+ this.app = app;
7
+ this.trackDir = trackDir;
8
+ this.routeSaveName = 'pending.jsonl';
9
+ this.routeSentName = 'sent.jsonl';
10
+ }
11
+
12
+ /**
13
+ * Migrate old track file names to new naming scheme
14
+ */
15
+ async migrate() {
16
+ const oldTrackFile = path.join(this.trackDir, 'nfl-track.jsonl');
17
+ const oldPendingFile = path.join(this.trackDir, 'nfl-track-pending.jsonl');
18
+ const oldSentFile = path.join(this.trackDir, 'nfl-track-sent.jsonl');
19
+ const newPendingFile = path.join(this.trackDir, this.routeSaveName);
20
+ const newSentFile = path.join(this.trackDir, this.routeSentName);
21
+
22
+ try {
23
+ // Migrate old track file
24
+ await this.migrateSingleFile(oldTrackFile, newPendingFile, 'track file');
25
+
26
+ // Migrate old pending file
27
+ await this.migrateSingleFile(oldPendingFile, newPendingFile, 'pending file');
28
+
29
+ // Migrate old sent file
30
+ await this.migrateSingleFile(oldSentFile, newSentFile, 'sent file');
31
+
32
+ // Check old plugin directory location
33
+ await this.migrateFromOldPluginDir(newPendingFile, newSentFile);
34
+
35
+ } catch (err) {
36
+ this.app.debug('Error during track file migration:', err.message);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Migrate a single file if it exists
42
+ */
43
+ async migrateSingleFile(oldPath, newPath, description) {
44
+ if (await fs.pathExists(oldPath) && !(await fs.pathExists(newPath))) {
45
+ this.app.debug(`Migrating old ${description} to new naming scheme...`);
46
+ await fs.move(oldPath, newPath);
47
+ this.app.debug(`Successfully migrated old ${description} to: ${path.basename(newPath)}`);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Migrate track files from old plugin directory location
53
+ */
54
+ async migrateFromOldPluginDir(newPendingFile, newSentFile) {
55
+ const oldPluginTrackDir = path.join(__dirname, '..', 'track');
56
+
57
+ if (!(await fs.pathExists(oldPluginTrackDir))) {
58
+ return;
59
+ }
60
+
61
+ this.app.debug('Found old track directory in plugin folder, migrating to new location...');
62
+
63
+ // Migrate pending files
64
+ const oldPendingFiles = [
65
+ 'nfl-track.jsonl',
66
+ 'nfl-track-pending.jsonl',
67
+ this.routeSaveName
68
+ ];
69
+
70
+ for (const oldFile of oldPendingFiles) {
71
+ const oldPath = path.join(oldPluginTrackDir, oldFile);
72
+ if (await fs.pathExists(oldPath) && !(await fs.pathExists(newPendingFile))) {
73
+ await fs.move(oldPath, newPendingFile);
74
+ this.app.debug('Migrated pending track file from old plugin location');
75
+ break;
76
+ }
77
+ }
78
+
79
+ // Migrate sent archive
80
+ const oldSentFiles = [this.routeSentName, 'nfl-track-sent.jsonl'];
81
+ for (const oldFile of oldSentFiles) {
82
+ const oldPath = path.join(oldPluginTrackDir, oldFile);
83
+ if (await fs.pathExists(oldPath) && !(await fs.pathExists(newSentFile))) {
84
+ await fs.move(oldPath, newSentFile);
85
+ this.app.debug('Migrated sent track archive from old plugin location');
86
+ break;
87
+ }
88
+ }
89
+
90
+ // Try to remove old directory if empty
91
+ await this.removeOldDirectoryIfEmpty(oldPluginTrackDir);
92
+ }
93
+
94
+ /**
95
+ * Remove old directory if it's empty
96
+ */
97
+ async removeOldDirectoryIfEmpty(dirPath) {
98
+ try {
99
+ const remainingFiles = await fs.readdir(dirPath);
100
+ if (remainingFiles.length === 0) {
101
+ await fs.rmdir(dirPath);
102
+ this.app.debug('Removed empty old track directory');
103
+ }
104
+ } catch (err) {
105
+ this.app.debug('Could not remove old track directory:', err.message);
106
+ }
107
+ }
108
+ }
109
+
110
+ module.exports = TrackMigration;
@@ -0,0 +1,219 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const readline = require('readline');
4
+ const fetch = require('node-fetch');
5
+
6
+ class TrackSender {
7
+ constructor(app, options, trackDir) {
8
+ this.app = app;
9
+ this.options = options;
10
+ this.trackDir = trackDir;
11
+ this.routeSaveName = 'pending.jsonl';
12
+ this.routeSentName = 'sent.jsonl';
13
+
14
+ this.apiUrl = 'https://www.noforeignland.com/home/api/v1/boat/tracking/track';
15
+ this.pluginApiKey = '0ede6cb6-5213-45f5-8ab4-b4836b236f97';
16
+ }
17
+
18
+ /**
19
+ * Check if boat is currently moving
20
+ */
21
+ isBoatMoving(lastPosition, upSince) {
22
+ if (!this.options.trackFrequency) {
23
+ return true;
24
+ }
25
+
26
+ const time = lastPosition ? lastPosition.currentTime : upSince;
27
+ const secsSinceLastPoint = (new Date().getTime() - time) / 1000;
28
+ const isMoving = secsSinceLastPoint <= (this.options.trackFrequency * 2);
29
+
30
+ if (isMoving) {
31
+ this.app.debug('Boat is still moving, last move', secsSinceLastPoint, 'seconds ago');
32
+ return this.options.sendWhileMoving;
33
+ } else {
34
+ this.app.debug('Boat stopped moving, last move at least', secsSinceLastPoint, 'seconds ago');
35
+ return true;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Check if track file exists and has content
41
+ */
42
+ async hasTrackData() {
43
+ const trackFile = path.join(this.trackDir, this.routeSaveName);
44
+ this.app.debug('checking the track', trackFile, 'if should send');
45
+
46
+ const exists = await fs.pathExists(trackFile);
47
+ const size = exists ? (await fs.lstat(trackFile)).size : 0;
48
+
49
+ this.app.debug(`'${trackFile}'.size=${size} ${trackFile}'.exists=${exists}`);
50
+ return size > 0;
51
+ }
52
+
53
+ /**
54
+ * Send track data to API
55
+ */
56
+ async sendTrack() {
57
+ if (!this.options.boatApiKey) {
58
+ throw new Error('No boat API key set in plugin settings.');
59
+ }
60
+
61
+ this.app.debug('sending the data');
62
+ const pendingFile = path.join(this.trackDir, this.routeSaveName);
63
+ const trackData = await this.createTrackFromFile(pendingFile);
64
+
65
+ if (!trackData) {
66
+ throw new Error('Recorded track did not contain any valid track points, aborting sending.');
67
+ }
68
+
69
+ this.app.debug('created track data with timestamp:', new Date(trackData.timestamp));
70
+
71
+ const params = new URLSearchParams();
72
+ params.append('timestamp', trackData.timestamp);
73
+ params.append('track', JSON.stringify(trackData.track));
74
+ params.append('boatApiKey', this.options.boatApiKey);
75
+
76
+ const headers = { 'X-NFL-API-Key': this.pluginApiKey };
77
+ this.app.debug('sending track to API');
78
+
79
+ const success = await this.sendWithRetry(params, headers);
80
+
81
+ if (success) {
82
+ await this.handleSuccessfulSend(pendingFile);
83
+ }
84
+
85
+ return success;
86
+ }
87
+
88
+ /**
89
+ * Send data with retry logic
90
+ */
91
+ async sendWithRetry(params, headers) {
92
+ const maxRetries = 3;
93
+ const baseTimeout = (this.options.apiTimeout || 30) * 1000;
94
+
95
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
96
+ try {
97
+ const currentTimeout = baseTimeout * attempt;
98
+ this.app.debug(`Attempt ${attempt}/${maxRetries} with ${currentTimeout}ms timeout`);
99
+
100
+ const controller = new AbortController();
101
+ const timeoutId = setTimeout(() => controller.abort(), currentTimeout);
102
+
103
+ const response = await fetch(this.apiUrl, {
104
+ method: 'POST',
105
+ body: params,
106
+ headers: new fetch.Headers(headers),
107
+ signal: controller.signal
108
+ });
109
+
110
+ clearTimeout(timeoutId);
111
+
112
+ if (response.ok) {
113
+ const responseBody = await response.json();
114
+ if (responseBody.status === 'ok') {
115
+ this.app.debug('Track successfully sent to API');
116
+ return true;
117
+ } else {
118
+ this.app.debug('Could not send track to API, returned response json:', responseBody);
119
+ throw new Error('API returned error.');
120
+ }
121
+ } else {
122
+ this.app.debug('Could not send track to API, returned response code:', response.status, response.statusText);
123
+ if (response.status >= 400 && response.status < 500) {
124
+ throw new Error(`HTTP ${response.status}.`);
125
+ }
126
+ throw new Error(`HTTP ${response.status}`);
127
+ }
128
+ } catch (err) {
129
+ this.app.debug(`Attempt ${attempt} failed:`, err.message);
130
+
131
+ if (attempt === maxRetries) {
132
+ this.app.debug('Could not send track to API after', maxRetries, 'attempts:', err);
133
+ throw new Error(`Failed to send track after ${maxRetries} attempts - check logs for details.`);
134
+ } else {
135
+ const waitTime = 2000 * attempt;
136
+ this.app.debug(`Waiting ${waitTime}ms before retry...`);
137
+ await new Promise(resolve => setTimeout(resolve, waitTime));
138
+ }
139
+ }
140
+ }
141
+
142
+ return false;
143
+ }
144
+
145
+ /**
146
+ * Handle successful send (archive or delete track file)
147
+ */
148
+ async handleSuccessfulSend(pendingFile) {
149
+ const sentFile = path.join(this.trackDir, this.routeSentName);
150
+
151
+ try {
152
+ if (this.options.keepFiles) {
153
+ this.app.debug('Appending sent data to archive file:', this.routeSentName);
154
+ const pendingContent = await fs.readFile(pendingFile, 'utf8');
155
+ await fs.appendFile(sentFile, pendingContent);
156
+ this.app.debug('Successfully archived sent track data');
157
+ } else {
158
+ this.app.debug('keepFiles disabled, will delete pending file');
159
+ }
160
+
161
+ this.app.debug('Deleting pending track file');
162
+ await fs.remove(pendingFile);
163
+ this.app.debug('Successfully processed track files after send');
164
+
165
+ } catch (err) {
166
+ this.app.debug('Error handling files after successful send:', err.message);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Create track data from file
172
+ */
173
+ async createTrackFromFile(inputPath) {
174
+ const fileStream = fs.createReadStream(inputPath);
175
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
176
+ const track = [];
177
+ let lastTimestamp;
178
+
179
+ for await (const line of rl) {
180
+ if (line) {
181
+ try {
182
+ const point = JSON.parse(line);
183
+ const timestamp = new Date(point.t).getTime();
184
+
185
+ if (!isNaN(timestamp) &&
186
+ this.isValidLatitude(point.lat) &&
187
+ this.isValidLongitude(point.lon)) {
188
+ track.push([timestamp, point.lat, point.lon]);
189
+ lastTimestamp = timestamp;
190
+ }
191
+ } catch (error) {
192
+ this.app.debug('could not parse line from track file:', line);
193
+ }
194
+ }
195
+ }
196
+
197
+ if (track.length > 0) {
198
+ return { timestamp: new Date(lastTimestamp).getTime(), track };
199
+ }
200
+
201
+ return null;
202
+ }
203
+
204
+ /**
205
+ * Validate latitude
206
+ */
207
+ isValidLatitude(obj) {
208
+ return (obj !== undefined && obj !== null && typeof obj === 'number' && obj > -90 && obj < 90);
209
+ }
210
+
211
+ /**
212
+ * Validate longitude
213
+ */
214
+ isValidLongitude(obj) {
215
+ return (obj !== undefined && obj !== null && typeof obj === 'number' && obj > -180 && obj < 180);
216
+ }
217
+ }
218
+
219
+ module.exports = TrackSender;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "signalk": {
4
4
  "id": "@noforeignland/signalk-to-noforeignland"
5
5
  },
6
- "version": "1.0.1-beta.8",
6
+ "version": "1.1.0-beta.11",
7
7
  "description": "SignalK track logger to noforeignland.com",
8
8
  "main": "index.js",
9
9
  "keywords": [
package/doc/dev DELETED
@@ -1,17 +0,0 @@
1
-
2
- INTERNAL NOTES ONLY, IGNORE.
3
-
4
-
5
- cd ~/dirk/nfl-signalk
6
-
7
- # Aktuelle Version zu Beta machen
8
- npm version 1.0.1-beta.1 --no-git-tag-version
9
-
10
- # Package bauen
11
- npm pack
12
-
13
- # Mit beta tag publishen (nicht als 'latest')
14
- npm publish --tag beta
15
-
16
- # Falls du noch nicht eingeloggt bist:
17
- npm login