Skip to content

Commit

Permalink
extension manager on the client for #28 #29
Browse files Browse the repository at this point in the history
ability to install extensions from server-side service, REST route and
settings page on the client
  • Loading branch information
lucafaggianelli committed May 17, 2020
1 parent 9953d61 commit 798a1ea
Show file tree
Hide file tree
Showing 8 changed files with 140 additions and 33 deletions.
50 changes: 37 additions & 13 deletions client/src/components/base/Input.vue
Original file line number Diff line number Diff line change
@@ -1,32 +1,56 @@
<template>
<div class="flex items-center mb-8 bg-gray-800">
<input
ref="input"
v-model="currentValue"
v-bind="$attrs"
class="flex-grow px-4 py-2 bg-transparent border-0"
@input="onInput"
>
<div class="items-center mb-8">
<div class="flex bg-gray-800">
<input
ref="input"
class="flex-grow px-4 py-2 bg-transparent border-0"
v-bind="$attrs"
:value="value"
v-on="inputListeners"
>
</div>

<div class="px-4 pt-1 text-sm text-error">
{{ errors[0] }}
</div>
</div>
</template>

<script>
export default {
inheritAttrs: false,
props: {
errors: {
type: Array,
default: () => []
},
value: {
type: [ String, Number ],
default: null
}
},
data () {
return {
currentValue: this.value,
valueWhenFocus: null
}
},
methods: {
onInput (val) {
this.$emit('input', this.currentValue)
computed: {
// From Vue docs:
// https://vuejs.org/v2/guide/components-custom-events.html#Binding-Native-Events-to-Components
inputListeners: function () {
var vm = this
// `Object.assign` merges objects together to form a new object
return Object.assign({},
// We add all the listeners from the parent
this.$listeners,
// Then we can add custom listeners or override the
// behavior of some listeners.
{
// This ensures that the component works with v-model
input: function (event) {
vm.$emit('input', event.target.value)
}
}
)
}
}
}
Expand Down
44 changes: 44 additions & 0 deletions client/src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@
{{ scanningLibrary ? 'Scanning...' : 'Scan Library' }}
</x-btn>
</div>

<div class="mb-8">
<h2 class="text-2xl mb-4">
Extensions
</h2>

<x-input
v-model.trim="extensionName"
:errors="[ extensionError ]"
placeholder="Install an extension"
:disabled="extensionInstalling"
@keypress.enter="installExtension"
/>

<div
v-for="ext in extensions"
:key="ext.id"
>
{{ ext.name }}
</div>
</div>
</div>
</template>

Expand All @@ -53,11 +74,18 @@ import client from '@/client'
export default {
data: () => ({
extensionError: null,
extensionInstalling: false,
extensionName: null,
extensions: [],
scanningLibrary: false
}),
computed: {
...mapState([ 'systemInfo' ])
},
async mounted () {
this.extensions = (await client.get('/api/extensions')).data
},
methods: {
async scanLibrary () {
try {
Expand All @@ -66,6 +94,22 @@ export default {
} finally {
this.scanningLibrary = false
}
},
async installExtension () {
if (this.extensionInstalling) { return }
this.extensionInstalling = true
this.extensionError = null
try {
await client.post('/api/extensions', {
name: this.extensionName
})
} catch (e) {
this.extensionError = e.toString()
} finally {
this.extensionInstalling = false
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"build": "tsc --build --clean && tsc",
"dev": "npm-run-all --parallel dev:js dev:ts",
"dev:js": "nodemon dist/src/app.js",
"dev:ts": "tsc -w",
"dev:ts": "tsc --watch --preserveWatchOutput",
"lint": "eslint ./src/**/*.{js,ts} --ignore-pattern *.d.ts",
"start": "node dist/src/app.js",
"test": "mocha",
Expand Down
9 changes: 9 additions & 0 deletions server/src/models/Extension.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { IsSemVer } from 'class-validator'
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn
} from 'typeorm'
Expand All @@ -18,13 +20,20 @@ export default class Extension extends BaseEntity {
@Column({
nullable: false
})
@Index({ unique: true })
name: string;

@Column({
nullable: false
})
path: string;

@Column({
nullable: false
})
@IsSemVer()
version: string;

@PrimaryGeneratedColumn()
id: number;

Expand Down
6 changes: 4 additions & 2 deletions server/src/routes/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ export async function install (ctx: Context) {
ctx.assert(name && name.length, 400, 'name is a required body param')

try {
await extensionsService.install(name)
ctx.status = 204
const installedExtension = await extensionsService.install(name)

ctx.status = 200
ctx.body = installedExtension
} catch (e) {
ctx.status = 400
ctx.body = e.message
Expand Down
32 changes: 21 additions & 11 deletions server/src/services/extensions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { CouchBuddyExtension } from 'couch-buddy-extensions'
import path from 'path'

import Extension from '../models/Extension'
import * as npm from './npm'
Expand All @@ -20,19 +19,30 @@ export default class ExtensionsService extends Service {
return null
}

private loadExtension (path: string): void {
allExtensions.push(require(path) as CouchBuddyExtension)
private loadExtension (path: string): boolean {
try {
allExtensions.push(require(path) as CouchBuddyExtension)
return true
} catch (e) {
console.log('Error loading extension', e)
return false
}
}

async install (extensionName: string): Promise<void> {
await npm.install(extensionName)
/**
* Install an extension
*
* @param packageName any package name supported by npm CLI,
* as in `npm install <name_here>`
*/
async install (packageName: string): Promise<Extension> {
const extension = await npm.install(packageName)

// Load the package and enable it only if the loading is successful
extension.enabled = this.loadExtension(extension.path)

const savedExtension = await Extension.create({
enabled: true,
name: extensionName,
path: path.join('node_modules', extensionName)
}).save()
await extension.save()

this.loadExtension(savedExtension.path)
return extension
}
}
27 changes: 22 additions & 5 deletions server/src/services/npm.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,46 @@
import npm from 'npm'

import Extension from '../models/Extension'

let npmLoaded = false

function loadNpm (): Promise<void> {
return new Promise((resolve, reject) => {
npm.load({}, (err) => {
npm.load({
save: false
}, (err) => {
if (err) { return reject(err) }

resolve()
})
})
}

export async function install (packageName: string): Promise<void> {
export async function install (packageName: string): Promise<Extension> {
if (!npmLoaded) {
await loadNpm()
npmLoaded = true
}

return new Promise((resolve, reject) => {
npm.commands.install([ packageName ], (err, ...results) => {
npm.commands.install([ packageName ], (err, results: string[][]) => {
// results from npm.install() is this:
// [
// [ 'fsevents@2.1.2', '/home/luca/dev/couch-buddy/server/node_modules/fsevents' ],
// [ 'couch-buddy-movies-explorer@1.0.1', '/home/luca/dev/couch-buddy/server/node_modules/couch-buddy-movies-explorer' ]
// ]
if (err) { return reject(err) }

console.log(`Package ${packageName} installed`, results)
resolve()
const installedPackage = results.pop()
const [ name, version ] = installedPackage[0].split(/(?<!^)@/)

const extension = Extension.create({
name,
path: installedPackage[1],
version
})

resolve(extension)
})
})
}
3 changes: 2 additions & 1 deletion server/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@
"include": [
"./src/**/*",
"./test/**/*"
]
],
"exclude": [ "node_modules", "package.json", "package-lock.json" ]
}

0 comments on commit 798a1ea

Please sign in to comment.