peertube-plugin-sell-storage 1.1.3

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/main.js ADDED
@@ -0,0 +1,628 @@
1
+ // const moment = require('moment');
2
+ const pjson = require("./package.json");
3
+ const express = require('express');
4
+ const Stripe = require('stripe')
5
+ let ALL_PLANS = [];
6
+ let STRIPE_KEY;
7
+
8
+ async function register({
9
+ registerHook,
10
+ registerSetting,
11
+ settingsManager,
12
+ storageManager,
13
+ videoCategoryManager,
14
+ videoLicenceManager,
15
+ videoLanguageManager,
16
+ getRouter,
17
+ peertubeHelpers
18
+ }) {
19
+ registerMenuSettings(registerSetting);
20
+ const router = getRouter();
21
+ STRIPE_KEY = await settingsManager.getSetting('stripe-secret-key');
22
+
23
+ // On reload settings
24
+ settingsManager.onSettingsChange(settings => {
25
+ STRIPE_KEY = settings['stripe-secret-key']
26
+
27
+ loadPlans(settings, settingsManager);
28
+ });
29
+ loadPlans(null, settingsManager);
30
+
31
+
32
+ router.post("/save-session-id", async (req, res) => {
33
+ try {
34
+ // Get current user
35
+ const user = await peertubeHelpers.user.getAuthUser(res);
36
+ if (!user) {
37
+ res.json({ status: "failure", message: "You are not allowed to do that." });
38
+ return;
39
+ }
40
+
41
+ const { session_id } = req.body;
42
+ if(!session_id) {
43
+ res.json({ status: "failure", message: "No session ID to save." });
44
+ return;
45
+ }
46
+
47
+ storageManager.storeData("ncd-sub-session-id-" + user.id, session_id)
48
+
49
+ res.json({
50
+ status: "success",
51
+ data: { session_id }
52
+ });
53
+
54
+ } catch (error) {
55
+ peertubeHelpers.logger.error(error.message, { error });
56
+ res.json({ status: "failure", message: error.message });
57
+ }
58
+ });
59
+
60
+
61
+ router.get("/get-session-id", async (req, res) => {
62
+
63
+ try {
64
+ // Get current user
65
+ const user = await peertubeHelpers.user.getAuthUser(res);
66
+ if (!user) {
67
+ res.json({ status: "failure", message: "You are not allowed to do that." });
68
+ return;
69
+ }
70
+
71
+ const session_id = await storageManager.getData("ncd-sub-session-id-" + user.id) || null;
72
+ const sub_plan = await storageManager.getData("ncd-sub-plan-" + user.id) || null;
73
+
74
+ res.json({
75
+ status: "success",
76
+ data: { session_id: session_id, sub_plan: sub_plan }
77
+ });
78
+
79
+ } catch (error) {
80
+ peertubeHelpers.logger.error(error.message, { error });
81
+ res.json({ status: "failure", message: error.message });
82
+ }
83
+ })
84
+
85
+
86
+ router.post('/create-checkout-session', async (req, res) => {
87
+ try {
88
+ // Get current user
89
+ const user = await peertubeHelpers.user.getAuthUser(res);
90
+ if (!user) {
91
+ res.json({ status: "failure", message: "You are not allowed to do that." });
92
+ return;
93
+ }
94
+
95
+ const sub_plan = await storageManager.getData("ncd-sub-plan-" + user.id) || null;
96
+ if(sub_plan) {
97
+ res.json({ status: "failure", message: "You already have a subscription plan. Please unsuscribe first!" });
98
+ return;
99
+ }
100
+
101
+ const stripe = Stripe(STRIPE_KEY);
102
+ const INSTANCE_URL = "https://" + req.get('host');
103
+ const { lookup_key } = req.body;
104
+
105
+ if(!lookup_key) {
106
+ res.json({ status: "failure", message: "No plan selected" });
107
+ }
108
+
109
+ const session = await stripe.checkout.sessions.create({
110
+ billing_address_collection: 'auto',
111
+ line_items: [
112
+ {
113
+ price: lookup_key,
114
+ // For metered billing, do not pass quantity
115
+ quantity: 1,
116
+ },
117
+ ],
118
+ subscription_data: {
119
+ metadata: {
120
+ user_id: user.id
121
+ }
122
+ },
123
+ mode: 'subscription',
124
+ success_url: `${INSTANCE_URL}/p/ncd-subscription-success?session_id={CHECKOUT_SESSION_ID}`,
125
+ cancel_url: `${INSTANCE_URL}/p/ncd-subscription-cancel`,
126
+ });
127
+
128
+ res.json({
129
+ status: "success",
130
+ data: { redirectUrl: session.url }
131
+ });
132
+
133
+ } catch(error) {
134
+ peertubeHelpers.logger.error(error.message, { error });
135
+ res.json({ status: "failure", message: error.message });
136
+ }
137
+
138
+ });
139
+
140
+
141
+ router.post('/create-portal-session', async (req, res) => {
142
+ try {
143
+ // Get current user
144
+ const user = await peertubeHelpers.user.getAuthUser(res);
145
+ if (!user) {
146
+ res.json({ status: "failure", message: "You are not allowed to do that." });
147
+ return;
148
+ }
149
+
150
+ const stripe = Stripe(STRIPE_KEY);
151
+ const INSTANCE_URL = "https://" + req.get('host');
152
+
153
+ // For demonstration purposes, we're using the Checkout session to retrieve the customer ID.
154
+ // Typically this is stored alongside the authenticated user in your database. (psql) users -> userID ->
155
+ const { session_id } = req.body;
156
+ const checkoutSession = await stripe.checkout.sessions.retrieve(session_id);
157
+
158
+ // This is the url to which the customer will be redirected when they are done
159
+ // managing their billing with the portal.
160
+ const returnUrl = INSTANCE_URL + "/p/ncd-my-subscription";
161
+
162
+ const portalSession = await stripe.billingPortal.sessions.create({
163
+ customer: checkoutSession.customer,
164
+ return_url: returnUrl,
165
+ });
166
+
167
+ res.json({
168
+ status: "success",
169
+ data: { redirectUrl: portalSession.url }
170
+ });
171
+
172
+ } catch(error) {
173
+ peertubeHelpers.logger.error(error.message, { error });
174
+ res.json({ status: "failure", message: error.message });
175
+ }
176
+ });
177
+
178
+
179
+
180
+ router.post(
181
+ '/webhook',
182
+ // express.raw({ type: 'application/json' }),
183
+ async (request, response) => {
184
+ try {
185
+ let event = request.body;
186
+ peertubeHelpers.logger.debug("[ncd-sell-storage/webhook] Received data", { event })
187
+
188
+ let subscription;
189
+ let status;
190
+
191
+ // Handle the event
192
+ switch (event.type) {
193
+ case 'customer.subscription.trial_will_end':
194
+ subscription = event.data.object;
195
+ status = subscription.status;
196
+ peertubeHelpers.logger.info(`Subscription status is ${status}.`);
197
+
198
+ // Then define and call a method to handle the subscription trial ending.
199
+ if(!status !== "active")
200
+ await handleSubscriptionEnd(subscription, peertubeHelpers, storageManager);
201
+ break;
202
+ case 'customer.subscription.deleted':
203
+ subscription = event.data.object;
204
+ status = subscription.status;
205
+ peertubeHelpers.logger.info(`Subscription status is ${status}.`);
206
+
207
+ // Then define and call a method to handle the subscription deleted.
208
+ if(!status !== "active")
209
+ await handleSubscriptionEnd(subscription, peertubeHelpers, storageManager);
210
+ break;
211
+ case 'customer.subscription.created':
212
+ subscription = event.data.object;
213
+ status = subscription.status;
214
+ peertubeHelpers.logger.info(`Subscription status is ${status}.`);
215
+
216
+ // Then define and call a method to handle the subscription created.
217
+ if(status === "trialing" || status === "active")
218
+ await handleSubscriptionStart(subscription, peertubeHelpers, storageManager);
219
+ break;
220
+ case 'customer.subscription.updated':
221
+ subscription = event.data.object;
222
+ status = subscription.status;
223
+ peertubeHelpers.logger.info(`Subscription status is ${status}.`);
224
+
225
+ // Then define and call a method to handle the subscription update.
226
+ if(status === "trialing" || status === "active")
227
+ await handleSubscriptionStart(subscription, peertubeHelpers, storageManager);
228
+ else
229
+ await handleSubscriptionEnd(subscription, peertubeHelpers);
230
+ break;
231
+ default:
232
+ // Unexpected event type
233
+ peertubeHelpers.logger.error(`Unhandled event type ${event.type}.`);
234
+ }
235
+
236
+ // Return a 200 response to acknowledge receipt of the event
237
+ response.send();
238
+
239
+ } catch (error) {
240
+ peertubeHelpers.logger.error(error.message, { error });
241
+ response.sendStatus(500);
242
+ }
243
+ }
244
+ );
245
+
246
+
247
+ }
248
+
249
+ async function unregister() {
250
+ return
251
+ }
252
+
253
+ module.exports = {
254
+ register,
255
+ unregister
256
+ }
257
+
258
+
259
+ async function loadPlans(settings, settingsManager) {
260
+ ALL_PLANS = [];
261
+
262
+ for(let i = 1; i <= 5; i++) {
263
+ const name = settings ? settings["plan-" + i + "-name"] : await settingsManager.getSetting("plan-" + i + "-name");
264
+ const key = settings ? settings["plan-" + i + "-key"] : await settingsManager.getSetting("plan-" + i + "-key");;
265
+ const storage = settings ? settings["plan-" + i + "-storage"] : await settingsManager.getSetting("plan-" + i + "-storage");;
266
+ const price = settings ? settings["plan-" + i + "-price"] : await settingsManager.getSetting("plan-" + i + "-price");;
267
+
268
+ ALL_PLANS.push({
269
+ name: name,
270
+ key: key,
271
+ storage: storage,
272
+ price: price
273
+ });
274
+ }
275
+ }
276
+
277
+
278
+ async function handleSubscriptionStart(subscription, peertubeHelpers, storageManager) {
279
+
280
+ try {
281
+ const sub_id = subscription.items.data[0].subscription;
282
+ const stripe = Stripe(STRIPE_KEY);
283
+
284
+ const sub_obj = await stripe.subscriptions.retrieve(sub_id);
285
+ if(!sub_obj) {
286
+ throw new Error("[handleSubscriptionStart] Invalid subscription provided.");
287
+ }
288
+
289
+ if(sub_obj.status !== "active") {
290
+ throw new Error("[handleSubscriptionStart] Provided subscription is NOT active.");
291
+ }
292
+
293
+ const price = subscription.plan.id;
294
+ const user_id = subscription.metadata.user_id;
295
+
296
+ const plan = ALL_PLANS.find(x => x.key == price);
297
+ if(!plan) {
298
+ peertubeHelpers.logger.error("[handleSubscriptionStart] No plan found mathing this subscription", { ALL_PLANS, subscription });
299
+ return;
300
+ }
301
+
302
+ storageManager.storeData("ncd-sub-plan-" + user_id, plan);
303
+ const quota = plan.storage * 1024 * 1024 * 1024;
304
+
305
+ const results = await peertubeHelpers.database.query(
306
+ 'UPDATE "user" SET "videoQuota" = $quota WHERE "id" = $id',
307
+ {
308
+ type: 'UPDATE',
309
+ bind: { quota: quota, id: user_id }
310
+ }
311
+ );
312
+
313
+ peertubeHelpers.logger.info(`[handleSubscriptionStart] Updated video quota to ${quota} for user id ${user_id}`, { plan, subscription })
314
+
315
+ } catch (error) {
316
+ peertubeHelpers.logger.error(error.message, { error });
317
+ }
318
+ }
319
+
320
+
321
+ async function handleSubscriptionEnd(subscription, peertubeHelpers, storageManager) {
322
+ try {
323
+ const sub_id = subscription.items.data[0].subscription;
324
+ const stripe = Stripe(STRIPE_KEY);
325
+
326
+ const sub_obj = await stripe.subscriptions.retrieve(sub_id);
327
+ if(!sub_obj) {
328
+ throw new Error("[handleSubscriptionStart] Invalid subscription provided.");
329
+ }
330
+
331
+ if(sub_obj.status == "active") {
332
+ throw new Error("[handleSubscriptionStart] Provided subscription is active. Can't end it now.");
333
+ }
334
+
335
+ const user_id = subscription.metadata.user_id;
336
+ storageManager.storeData("ncd-sub-plan-" + user_id, null);
337
+
338
+ const configs = await peertubeHelpers.config.getServerConfig();
339
+ const quota = configs.user.videoQuota;
340
+
341
+ const results = await peertubeHelpers.database.query(
342
+ 'UPDATE "user" SET "videoQuota" = $quota WHERE "id" = $id',
343
+ {
344
+ type: 'UPDATE',
345
+ bind: { quota: quota, id: user_id }
346
+ }
347
+ );
348
+
349
+ peertubeHelpers.logger.info(`[handleSubscriptionEnd] Updated video quota to ${quota} for user id ${user_id}`, { subscription })
350
+
351
+ } catch (error) {
352
+ peertubeHelpers.logger.error(error.message, { error });
353
+ }
354
+ }
355
+
356
+
357
+ function registerMenuSettings(registerSetting) {
358
+
359
+ // Stripe settings
360
+ registerSetting({
361
+ type: 'html',
362
+ html: '<h3>Stripe Settings</h3>'
363
+ })
364
+
365
+ registerSetting({
366
+ name: "stripe-secret-key",
367
+ label: "Stripe secret API key",
368
+ type: "input",
369
+ private: true,
370
+ descriptionHTML: "Your Stripe secret API key. Signup on <a href='https://www.stripe.com' target='blank_'>Stripe.com</a>",
371
+ default: ""
372
+ });
373
+
374
+ registerSetting({
375
+ type: 'html',
376
+ html: '<h4>Stripe Webhook</h4><p>You need to create a webhook in stripe. Set the stripe webhook endpoint to <b>https://your-instance.tld/plugins/ncd-sell-storage/'+pjson.version+'/router/webhook</b></p>'
377
+ })
378
+
379
+ registerSetting({
380
+ name: "sell-currency",
381
+ label: "Currency",
382
+ type: "input",
383
+ private: false,
384
+ descriptionHTML: "Currency to show in price",
385
+ default: "€"
386
+ });
387
+
388
+ registerSetting({
389
+ name: "sell-description",
390
+ label: "Page description",
391
+ type: "markdown-enhanced",
392
+ private: false,
393
+ descriptionHTML: "You can explain what you want, it is showed on the page. Leave it empty to show default localized description.",
394
+ default: "You **want tu spport us** ? Or **need more space** ? Your in the right place!"
395
+ });
396
+
397
+ registerSetting({
398
+ name: "sell-thx-description",
399
+ label: "Thank you page description",
400
+ type: "markdown-enhanced",
401
+ private: false,
402
+ descriptionHTML: "If you want to show a text on the Success page after payment",
403
+ default: ""
404
+ });
405
+
406
+ registerSetting({
407
+ name: "sell-cancel-description",
408
+ label: "Cancel page description",
409
+ type: "markdown-enhanced",
410
+ private: false,
411
+ descriptionHTML: "If you want to show a text on the Cancel page after payment canceled",
412
+ default: ""
413
+ });
414
+
415
+
416
+ // Products settings
417
+ registerSetting({
418
+ type: 'html',
419
+ html: '<br><h3>Manage Subscription</h3>'
420
+ })
421
+
422
+
423
+ // Plan 1
424
+ registerSetting({
425
+ type: 'html',
426
+ html: '<br><h4>Plan 1</h4>'
427
+ })
428
+ registerSetting({
429
+ name: "plan-1-name",
430
+ label: "Plan name",
431
+ type: "input",
432
+ private: false,
433
+ descriptionHTML: "Specify the name of your plan",
434
+ default: "Starter plan",
435
+ });
436
+
437
+ registerSetting({
438
+ name: "plan-1-storage",
439
+ label: "Available storage (in GB)",
440
+ type: "input",
441
+ private: false,
442
+ descriptionHTML: "Specify the amount of available space storage",
443
+ default: 150,
444
+ });
445
+
446
+ registerSetting({
447
+ name: "plan-1-price",
448
+ label: "Plan price /month",
449
+ type: "input",
450
+ private: false,
451
+ descriptionHTML: "Specify the price /month users pay for this plan",
452
+ default: 5,
453
+ });
454
+
455
+ registerSetting({
456
+ name: "plan-1-key",
457
+ label: "Product ID (API ID)",
458
+ type: "input",
459
+ private: false,
460
+ descriptionHTML: "Specify the product ID that represent the product in Stripe (Ex: price_1LlHY6KHtJzgTzXzZTBRHkPs)",
461
+ default: "starter",
462
+ });
463
+
464
+
465
+ // Plan 2
466
+ registerSetting({
467
+ type: 'html',
468
+ html: '<br><h4>Plan 2</h4>'
469
+ })
470
+ registerSetting({
471
+ name: "plan-2-name",
472
+ label: "Plan name",
473
+ type: "input",
474
+ private: false,
475
+ descriptionHTML: "Specify the name of your plan",
476
+ default: "Community plan",
477
+ });
478
+
479
+ registerSetting({
480
+ name: "plan-2-storage",
481
+ label: "Available storage (in GB)",
482
+ type: "input",
483
+ private: false,
484
+ descriptionHTML: "Specify the amount of available space storage",
485
+ default: 300,
486
+ });
487
+
488
+ registerSetting({
489
+ name: "plan-2-price",
490
+ label: "Plan price /month",
491
+ type: "input",
492
+ private: false,
493
+ descriptionHTML: "Specify the price /month users pay for this plan",
494
+ default: 10,
495
+ });
496
+
497
+ registerSetting({
498
+ name: "plan-2-key",
499
+ label: "Product ID (API ID)",
500
+ type: "input",
501
+ private: false,
502
+ descriptionHTML: "Specify the product ID that represent the product in Stripe (Ex: price_1LlHY6KHtJzgTzXzZTBRHkPs)",
503
+ default: "community",
504
+ });
505
+
506
+ // Plan 3
507
+ registerSetting({
508
+ type: 'html',
509
+ html: '<br><h4>Plan 3</h4>'
510
+ })
511
+ registerSetting({
512
+ name: "plan-3-name",
513
+ label: "Plan name",
514
+ type: "input",
515
+ private: false,
516
+ descriptionHTML: "Specify the name of your plan",
517
+ default: "Profesionnal plan",
518
+ });
519
+
520
+ registerSetting({
521
+ name: "plan-3-storage",
522
+ label: "Available storage (in GB)",
523
+ type: "input",
524
+ private: false,
525
+ descriptionHTML: "Specify the amount of available space storage",
526
+ default: 1000,
527
+ });
528
+
529
+ registerSetting({
530
+ name: "plan-3-price",
531
+ label: "Plan price /month",
532
+ type: "input",
533
+ private: false,
534
+ descriptionHTML: "Specify the price /month users pay for this plan",
535
+ default: 15,
536
+ });
537
+
538
+ registerSetting({
539
+ name: "plan-3-key",
540
+ label: "Product ID (API ID)",
541
+ type: "input",
542
+ private: false,
543
+ descriptionHTML: "Specify the product ID that represent the product in Stripe (Ex: price_1LlHY6KHtJzgTzXzZTBRHkPs)",
544
+ default: "profesionnal",
545
+ });
546
+
547
+ // Plan 4
548
+ registerSetting({
549
+ type: 'html',
550
+ html: '<br><h4>Plan 4</h4>'
551
+ })
552
+ registerSetting({
553
+ name: "plan-4-name",
554
+ label: "Plan name",
555
+ type: "input",
556
+ private: false,
557
+ descriptionHTML: "Specify the name of your plan",
558
+ default: "Profesionnal plan",
559
+ });
560
+
561
+ registerSetting({
562
+ name: "plan-4-storage",
563
+ label: "Available storage (in GB)",
564
+ type: "input",
565
+ private: false,
566
+ descriptionHTML: "Specify the amount of available space storage",
567
+ default: 1000,
568
+ });
569
+
570
+ registerSetting({
571
+ name: "plan-4-price",
572
+ label: "Plan price /month",
573
+ type: "input",
574
+ private: false,
575
+ descriptionHTML: "Specify the price /month users pay for this plan",
576
+ default: 15,
577
+ });
578
+
579
+ registerSetting({
580
+ name: "plan-4-key",
581
+ label: "Product ID (API ID)",
582
+ type: "input",
583
+ private: false,
584
+ descriptionHTML: "Specify the product ID that represent the product in Stripe (Ex: price_1LlHY6KHtJzgTzXzZTBRHkPs)",
585
+ default: "profesionnal",
586
+ });
587
+
588
+ // Plan 5
589
+ registerSetting({
590
+ type: 'html',
591
+ html: '<br><h4>Plan 5</h4>'
592
+ })
593
+ registerSetting({
594
+ name: "plan-5-name",
595
+ label: "Plan name",
596
+ type: "input",
597
+ private: false,
598
+ descriptionHTML: "Specify the name of your plan",
599
+ default: "Profesionnal plan",
600
+ });
601
+
602
+ registerSetting({
603
+ name: "plan-5-storage",
604
+ label: "Available storage (in GB)",
605
+ type: "input",
606
+ private: false,
607
+ descriptionHTML: "Specify the amount of available space storage",
608
+ default: 1000,
609
+ });
610
+
611
+ registerSetting({
612
+ name: "plan-5-price",
613
+ label: "Plan price /month",
614
+ type: "input",
615
+ private: false,
616
+ descriptionHTML: "Specify the price /month users pay for this plan",
617
+ default: 15,
618
+ });
619
+
620
+ registerSetting({
621
+ name: "plan-5-key",
622
+ label: "Product ID (API ID)",
623
+ type: "input",
624
+ private: false,
625
+ descriptionHTML: "Specify the product ID that represent the product in Stripe (Ex: price_1LlHY6KHtJzgTzXzZTBRHkPs)",
626
+ default: "profesionnal",
627
+ });
628
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "peertube-plugin-sell-storage",
3
+ "description": "Updated and republished version of peertube-plugin-ncd-sell-storage to sell storage to users using Stripe",
4
+ "version": "1.1.3",
5
+ "author": "AyaaDev",
6
+ "bugs": "https://github.com/ayaadev/peertube-plugin-sell-storage",
7
+ "clientScripts": [
8
+ {
9
+ "script": "dist/common-client-plugin.js",
10
+ "scopes": [
11
+ "common"
12
+ ]
13
+ }
14
+ ],
15
+ "css": [
16
+ "assets/style.css"
17
+ ],
18
+ "engine": {
19
+ "peertube": ">=4.3.0"
20
+ },
21
+ "homepage": "https://github.com/ayaadev/peertube-plugin-sell-storage",
22
+ "keywords": [
23
+ "peertube",
24
+ "plugin",
25
+ "storage",
26
+ "admin",
27
+ "stats"
28
+ ],
29
+ "library": "./main.js",
30
+ "scripts": {
31
+ "prepare": "npm run build",
32
+ "build": "node ./scripts/build.js",
33
+ "pl:install": "cd ../PeerTube && npm run plugin:uninstall -- --npm-name peertube-plugin-sell-storage && npm run plugin:install -- --plugin-path /home/aya/Desktop/GitHub/peertube-plugin-sell-storage"
34
+ },
35
+ "staticDirs": {
36
+ "images": "public/images"
37
+ },
38
+ "translations": {
39
+ "fr-FR": "./languages/fr.json"
40
+ },
41
+ "devDependencies": {
42
+ "esbuild": "^0.14.36"
43
+ },
44
+ "dependencies": {
45
+ "express": "^4.18.1",
46
+ "moment": "^2.29.4",
47
+ "stripe": "^10.11.0"
48
+ },
49
+ "main": "main.js",
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "https://github.com/ayaadev/peertube-plugin-sell-storage"
53
+ },
54
+ "license": "ISC"
55
+ }
File without changes
@@ -0,0 +1,20 @@
1
+ const path = require('path')
2
+ const esbuild = require('esbuild')
3
+
4
+ const clientFiles = [
5
+ 'common-client-plugin.js'
6
+ ]
7
+
8
+ const configs = clientFiles.map(f => ({
9
+ entryPoints: [ path.resolve(__dirname, '..', 'client', f) ],
10
+ bundle: true,
11
+ minify: true,
12
+ format: 'esm',
13
+ target: 'safari11',
14
+ outfile: path.resolve(__dirname, '..', 'dist', f),
15
+ }))
16
+
17
+ const promises = configs.map(c => esbuild.build(c))
18
+
19
+ Promise.all(promises)
20
+ .catch(() => process.exit(1))
package/shell.nix ADDED
@@ -0,0 +1,7 @@
1
+ let
2
+ pkgs = import <nixpkgs> {};
3
+ in pkgs.mkShell {
4
+ packages = [
5
+ pkgs.nodejs
6
+ ];
7
+ }