├── .nvmrc ├── .gitattributes ├── icon.png ├── media ├── logo-hugo.ai ├── logo-hugo.png ├── update-action.png └── logo-hugo.svg ├── src ├── index.ts ├── types │ ├── plist.d.ts │ └── bplist.d.ts ├── utils.ts ├── bin │ ├── unlink.ts │ └── link.ts ├── file-cache.ts ├── action.ts ├── types.ts ├── updater.ts └── hugo.ts ├── test ├── snapshots │ ├── output.ts.snap │ ├── feedback.ts.snap │ ├── matching.ts.snap │ ├── feedback.ts.md │ ├── matching.ts.md │ └── output.ts.md ├── updates │ ├── snapshots │ │ ├── updater.ts.snap │ │ └── updater.ts.md │ ├── packal.ts │ ├── updater.ts │ └── npm.ts ├── helpers │ ├── types.ts │ ├── mocks │ │ ├── appcast-noversion.xml │ │ ├── appcast.xml │ │ └── appcast-invalidversion.xml │ ├── init.ts │ └── mock.ts ├── meta │ ├── theme.ts │ ├── workflow.ts │ └── alfred.ts ├── feedback.ts ├── file-cache.ts ├── output.ts ├── fetch.ts ├── matching.ts ├── hugo.ts ├── utils.ts └── actions.ts ├── .gitignore ├── tsconfig.dist.json ├── .nycrc ├── .editorconfig ├── tsconfig.json ├── ISSUE_TEMPLATE.md ├── .circleci └── config.yml ├── docs ├── match.md ├── config.md ├── fetch.md ├── file-cache.md ├── cache.md ├── updates.md ├── items.md ├── metadata.md └── actions.md ├── LICENSE ├── CONTRIBUTING.md ├── package.json ├── README.md ├── CODE_OF_CONDUCT.md └── .eslintrc.js /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/dubnium 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.ai -diff 2 | *.svg -diff 3 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloudstek/alfred-hugo/HEAD/icon.png -------------------------------------------------------------------------------- /media/logo-hugo.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloudstek/alfred-hugo/HEAD/media/logo-hugo.ai -------------------------------------------------------------------------------- /media/logo-hugo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloudstek/alfred-hugo/HEAD/media/logo-hugo.png -------------------------------------------------------------------------------- /media/update-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloudstek/alfred-hugo/HEAD/media/update-action.png -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export { Hugo } from './hugo'; 3 | export { Updater } from './updater'; 4 | -------------------------------------------------------------------------------- /test/snapshots/output.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloudstek/alfred-hugo/HEAD/test/snapshots/output.ts.snap -------------------------------------------------------------------------------- /test/snapshots/feedback.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloudstek/alfred-hugo/HEAD/test/snapshots/feedback.ts.snap -------------------------------------------------------------------------------- /test/snapshots/matching.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloudstek/alfred-hugo/HEAD/test/snapshots/matching.ts.snap -------------------------------------------------------------------------------- /test/updates/snapshots/updater.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloudstek/alfred-hugo/HEAD/test/updates/snapshots/updater.ts.snap -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | yarn-error.log 3 | .DS_Store 4 | 5 | /.nyc_output 6 | /build 7 | /coverage 8 | /dist 9 | .idea 10 | -------------------------------------------------------------------------------- /src/types/plist.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'plist' { 2 | export function parse(string: string): any; 3 | export function build(json: any[]): string; 4 | } 5 | -------------------------------------------------------------------------------- /test/helpers/types.ts: -------------------------------------------------------------------------------- 1 | import { Hugo, Item } from '../../src'; 2 | 3 | export interface TestContext { 4 | hugo?: Hugo; 5 | url?: string; 6 | urlHash?: string; 7 | items?: Item[]; 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "outDir": "dist" 6 | }, 7 | "include": [ 8 | "src/**/*" 9 | ], 10 | "exclude": [ 11 | "test/**/*" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "extension": [ 3 | ".js", 4 | ".ts" 5 | ], 6 | "include": ["src/**/*.ts", "build/src/**/*.js"], 7 | "reporter": [ 8 | "text", 9 | "html" 10 | ], 11 | "cache": true, 12 | "sourceMap": true, 13 | "instrument": true 14 | } 15 | -------------------------------------------------------------------------------- /test/helpers/mocks/appcast-noversion.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | My Workflow 4 | my.work.flow 5 | 1486081099 6 | myworkflow.alfredworkflow 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/helpers/mocks/appcast.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | My Workflow 4 | 2.0.0 5 | my.work.flow 6 | 1486081099 7 | myworkflow.alfredworkflow 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/types/bplist.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'bplist-parser' { 2 | function parseFile(fileNameOrBuffer: string | Buffer, callback?: (error: Error, result: any) => void): any; 3 | function parseBuffer(buffer: Buffer): any; 4 | } 5 | 6 | declare module 'bplist-creator' { 7 | export default function(dicts: any): Buffer; 8 | } 9 | -------------------------------------------------------------------------------- /test/helpers/mocks/appcast-invalidversion.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | My Workflow 4 | foobar 5 | my.work.flow 6 | 1486081099 7 | myworkflow.alfredworkflow 8 | 9 | 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.{json,yml}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [.*rc] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [*.md] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "lib": ["es2017"], 6 | "strict": false, 7 | "noImplicitAny": true, 8 | "declaration": true, 9 | "declarationMap": false, 10 | "sourceMap": true, 11 | "esModuleInterop": true, 12 | "outDir": "build" 13 | }, 14 | "include": [ 15 | "src/**/*", 16 | "test/**/*" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | [Short description of your problem] 2 | 3 | **Hugo version:** [Enter your Hugo version or commit hash] 4 | **Alfred version:** [Enter your Alfred version] 5 | **NodeJS version:** [Enter your NodeJS version] 6 | 7 | […] any other relevant libraries/tools like Babel and their versions 8 | 9 | ```javascript 10 | // Code that causes the unfortunate undocumented behaviour 11 | ``` 12 | 13 | **Exception / Stacktrace:** 14 | 15 | ```bash 16 | [Exception message / stacktrace here] 17 | ``` 18 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | shared: &shared 4 | steps: 5 | - checkout 6 | - run: yarn 7 | - run: yarn test:ci 8 | 9 | jobs: 10 | test-node-10: 11 | docker: 12 | - image: node:10 13 | <<: *shared 14 | 15 | test-node-12: 16 | docker: 17 | - image: node:12 18 | <<: *shared 19 | 20 | test-node-13: 21 | docker: 22 | - image: node:13 23 | <<: *shared 24 | 25 | workflows: 26 | version: 2 27 | test: 28 | jobs: 29 | - test-node-10 30 | - test-node-12 31 | - test-node-13 32 | -------------------------------------------------------------------------------- /test/meta/theme.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import fs from 'fs-extra'; 3 | import path from 'path'; 4 | 5 | import { hugo } from '../helpers/init'; 6 | 7 | test.serial('existing theme', (t) => { 8 | const h = hugo(); 9 | 10 | process.env.alfred_theme = 'foo'; 11 | 12 | const themeFilePath = path.resolve(process.env.alfred_preferences, 'themes', process.env.alfred_theme, 'theme.json'); 13 | 14 | fs.ensureFileSync(themeFilePath); 15 | fs.writeJsonSync(themeFilePath, { 16 | alfredtheme: { 17 | foo: 'bar', 18 | }, 19 | }); 20 | 21 | t.is(typeof h.alfredTheme, 'object'); 22 | t.deepEqual(h.alfredTheme, { 23 | foo: 'bar', 24 | }); 25 | }); 26 | 27 | test.serial('non-existing theme', (t) => { 28 | const h = hugo(); 29 | 30 | // Valid theme name but directory doesn't exist. 31 | process.env.alfred_theme = 'foo'; 32 | 33 | t.is(h.alfredTheme, null); 34 | }); 35 | -------------------------------------------------------------------------------- /docs/match.md: -------------------------------------------------------------------------------- 1 | # Matching 2 | 3 | Alfred has a [pretty good matching](https://www.alfredapp.com/help/workflows/inputs/script-filter/#alfred-filters-results) algorithm which might good enough for most purposes. However, if you need more control over how your items are filtered you can use Hugo's `match` method which is a simple wrapper around [Fuse.js](https://fusejs.io/). 4 | 5 | ### Example 6 | 7 | ```js 8 | import { Hugo } from 'alfred-hugo'; 9 | 10 | const hugo = new Hugo(); 11 | 12 | // Add multiple items 13 | hugo.items.push( 14 | { 15 | title: 'Foo', 16 | subtitle: 'Bar', 17 | arg: 'foobar' 18 | }, 19 | { 20 | title: 'Apple', 21 | subtitle: 'Pie', 22 | arg: 'omnomnom' 23 | } 24 | ); 25 | 26 | // Match items on arg 27 | const results = hugo.match(hugo.items, 'omnom', { 28 | keys: ['arg'] 29 | }); 30 | 31 | // Filter items, discarding non-matching items 32 | hugo.items = results; 33 | ``` 34 | 35 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # Config 2 | 3 | Hugo provides an easy way to store your workflow configuration by providing you with a simple key/value store that is automatically stored in a JSON file. No need to deal with handling files and serialising data, simply get and set your values. 4 | 5 | Under the hood this uses the same package which handles the [cache](./cache.md) in Hugo, but with expiration of items disabled. For more details about the [Cache](https://www.npmjs.com/package/@cloudstek/cache) package, please see: https://www.npmjs.com/package/@cloudstek/cache. 6 | 7 | You *can* let a configuration item expire but that is entirely optional and I'm not sure why you'd want that. Use the [cache](./cache) instead :wink: 8 | 9 | ### Example 10 | 11 | ```js 12 | import { Hugo } from 'alfred-hugo'; 13 | 14 | const hugo = new Hugo(); 15 | 16 | // No need to load anything, just use it! 17 | 18 | // Store 'foo' with value 'bar' 19 | hugo.config.set('foo', 'bar'); 20 | 21 | // Get 'foo' 22 | console.log(hugo.config.get('foo')); // bar 23 | ``` 24 | 25 | -------------------------------------------------------------------------------- /test/snapshots/feedback.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/feedback.ts` 2 | 3 | The actual snapshot is saved in `feedback.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## check for updates on feedback 8 | 9 | > Snapshot 1 10 | 11 | `{␊ 12 | "items": [␊ 13 | {␊ 14 | "title": "Workflow update available!",␊ 15 | "subtitle": "Version 2.0.0 is available. Current version: 1.0.0.",␊ 16 | "icon": {␊ 17 | "path": "/foo/bar/icon.png"␊ 18 | },␊ 19 | "arg": "https://encrypted.google.com/search?sourceid=chrome&ie=UTF-8&q=site%3Apackal.org%20my.work.flow&btnI",␊ 20 | "variables": {␊ 21 | "task": "wfUpdate"␊ 22 | }␊ 23 | }␊ 24 | ],␊ 25 | "variables": {}␊ 26 | }` 27 | 28 | ## feedback without checking for updates 29 | 30 | > Snapshot 1 31 | 32 | `{␊ 33 | "rerun": 3.2,␊ 34 | "items": [␊ 35 | {␊ 36 | "title": "foo"␊ 37 | }␊ 38 | ],␊ 39 | "variables": {␊ 40 | "foo": "bar"␊ 41 | }␊ 42 | }` 43 | -------------------------------------------------------------------------------- /test/updates/snapshots/updater.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/updates/updater.ts` 2 | 3 | The actual snapshot is saved in `updater.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## check update item 8 | 9 | > Snapshot 1 10 | 11 | { 12 | arg: 'https://encrypted.google.com/search?sourceid=chrome&ie=UTF-8&q=site%3Apackal.org%20my.work.flow&btnI', 13 | icon: { 14 | path: '/foo/bar/icon.png', 15 | }, 16 | subtitle: 'Version 2.0.0 is available. Current version: 1.0.0.', 17 | title: 'Workflow update available!', 18 | variables: { 19 | task: 'wfUpdate', 20 | }, 21 | } 22 | 23 | ## update item only 24 | 25 | > Snapshot 1 26 | 27 | { 28 | arg: 'https://encrypted.google.com/search?sourceid=chrome&ie=UTF-8&q=site%3Apackal.org%20my.work.flow&btnI', 29 | icon: { 30 | path: '/foo/bar/icon.png', 31 | }, 32 | subtitle: 'Version 2.0.0 is available. Current version: 1.0.0.', 33 | title: 'Workflow update available!', 34 | variables: { 35 | task: 'wfUpdate', 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2019, Cloudstek All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the 4 | following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following 7 | disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following 10 | disclaimer in the documentation and/or other materials provided with the distribution. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 13 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 14 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 15 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 16 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 17 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 18 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 19 | -------------------------------------------------------------------------------- /docs/fetch.md: -------------------------------------------------------------------------------- 1 | # Fetch 2 | 3 | Hugo has a built-in method to make fetching data from other sites (e.g. REST APIs) a breeze and if you like, it can also cache the result for you, leveraging the built-in [cache](./cache.md). 4 | 5 | Behind the scenes [Axios](https://github.com/axios/axios) is used to make the request and by default it automatically parses JSON for you. This means you can directly use your data. 6 | 7 | *Note that this is only a simple wrapper function to make a GET request. Though you can specify the options for Axios, you might want to look into using Axios (or other libraries) directly if you need advanced options.* 8 | 9 | ### Example 10 | 11 | ```js 12 | import { Hugo } from 'alfred-hugo'; 13 | 14 | const hugo = new Hugo(); 15 | 16 | // Fetch single todo item without caching 17 | hugo 18 | .fetch('https://jsonplaceholder.typicode.com/todos/1') 19 | .then((data) => { 20 | console.log(`#${data.id} ${data.title}`); 21 | }); 22 | 23 | // Fetch todo list and cache it for 1 hour 24 | hugo 25 | .fetch('https://jsonplaceholder.typicode.com/todos', {/* Axios options */}, 3600) 26 | .then((data) => { 27 | console.log(`${data.length} todo items fetched.`); 28 | }); 29 | 30 | // Fetch the todo list again, this time it's cached! 31 | hugo 32 | .fetch('https://jsonplaceholder.typicode.com/todos', {/* Axios options */}, 3600) 33 | .then((data) => { 34 | console.log(`${data.length} todo items loaded from cache.`); 35 | }); 36 | ``` 37 | 38 | -------------------------------------------------------------------------------- /docs/file-cache.md: -------------------------------------------------------------------------------- 1 | # File Cache 2 | 3 | Processing files is often takes time and waste of resources when the file hardly ever changes. Caching the processed data isn't very hard but you'd still have to check if the file has been changed over time and reprocess it, cache it again and so on. 4 | 5 | The built-in file cache (for lack of a better name) does all this for you and is built upon the built-in [cache](./cache). Take a look at the examples on how to use it. 6 | 7 | By default the TTL (cache lifetime) is set to `false`, which means the results are cached indefinitely until the file has been changed. You can force a different TTL by 8 | 9 | ### Example 10 | 11 | ```js 12 | import { Hugo } from 'alfred-hugo'; 13 | 14 | const hugo = new Hugo(); 15 | 16 | // Create the file cache 17 | const fc = hugo.cacheFile('/path/to/file.to.process'); 18 | 19 | // Define the change handler for processing your file 20 | fc.on('change', (cache, fileContents) => { 21 | // Process the file contents 22 | const foo = processFoo(fileContents); 23 | const bar = processBar(fileContents); 24 | 25 | // Set the results 26 | cache.set('foo', foo); 27 | cache.set('bar', bar); 28 | }); 29 | 30 | // Get the results 31 | const results = fc.get(); 32 | 33 | console.log(results.foo); // Will output processed foo data 34 | console.log(results.bar); // Will output processed bar data 35 | ``` 36 | 37 | #### With a 1-hour TTL 38 | 39 | ```js 40 | import { Hugo } from 'alfred-hugo'; 41 | 42 | const hugo = new Hugo(); 43 | 44 | // Create the file cache 45 | const fc = hugo.cacheFile('/path/to/file.to.process', { ttl: 3600 }); 46 | 47 | // ... see example above. 48 | ``` 49 | 50 | -------------------------------------------------------------------------------- /test/snapshots/matching.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/matching.ts` 2 | 3 | The actual snapshot is saved in `matching.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## exact match multiple keys 8 | 9 | > Snapshot 1 10 | 11 | [ 12 | { 13 | subtitle: 'foo bar', 14 | title: 'Foo', 15 | }, 16 | { 17 | subtitle: 'ploop', 18 | title: 'Foo bar', 19 | }, 20 | { 21 | subtitle: 'foo bar bleep', 22 | title: 'Bar', 23 | }, 24 | { 25 | match: 'Abra', 26 | subtitle: 'eep foo blep', 27 | title: 'Eep', 28 | }, 29 | ] 30 | 31 | ## no query should return all items 32 | 33 | > Snapshot 1 34 | 35 | [ 36 | { 37 | subtitle: 'foo bar', 38 | title: 'Foo', 39 | }, 40 | { 41 | subtitle: 'foo bar bleep', 42 | title: 'Bar', 43 | }, 44 | { 45 | match: 'Abra', 46 | subtitle: 'eep foo blep', 47 | title: 'Eep', 48 | }, 49 | { 50 | subtitle: 'ploop', 51 | title: 'Foo bar', 52 | }, 53 | { 54 | subtitle: 'cadabra', 55 | title: 'Abra', 56 | }, 57 | ] 58 | 59 | ## exact match multiple keys using weighted syntax 60 | 61 | > Snapshot 1 62 | 63 | [ 64 | { 65 | subtitle: 'foo bar', 66 | title: 'Foo', 67 | }, 68 | { 69 | subtitle: 'foo bar bleep', 70 | title: 'Bar', 71 | }, 72 | { 73 | match: 'Abra', 74 | subtitle: 'eep foo blep', 75 | title: 'Eep', 76 | }, 77 | { 78 | subtitle: 'ploop', 79 | title: 'Foo bar', 80 | }, 81 | ] 82 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import untildify from 'untildify'; 3 | import bplist from 'bplist-parser'; 4 | import semver from 'semver'; 5 | 6 | class Utils{ 7 | /** 8 | * Check if file exists 9 | */ 10 | fileExists(file: string): boolean { 11 | try { 12 | fs.statSync(untildify(file)); 13 | return true; 14 | } catch (err) { 15 | return false; 16 | } 17 | } 18 | 19 | /** 20 | * Resolve alfred preferences file path 21 | */ 22 | resolveAlfredPrefs(version: string | semver.SemVer): string | null { 23 | if (typeof version === 'string') { 24 | version = semver.coerce(version); 25 | } 26 | 27 | let plistPath = untildify('~/Library/Preferences/com.runningwithcrayons.Alfred-Preferences.plist'); 28 | let settingsPath = untildify('~/Library/Application Support/Alfred'); 29 | 30 | if ((this.fileExists(plistPath) === false && version !== null) || version.major <= 3) { 31 | plistPath = untildify( 32 | `~/Library/Preferences/com.runningwithcrayons.Alfred-Preferences-${version.major}.plist` 33 | ); 34 | settingsPath = untildify(`~/Library/Application Support/Alfred ${version.major}`); 35 | 36 | if (this.fileExists(plistPath) === false) { 37 | throw new Error(`Alfred preferences not found at location ${plistPath}`); 38 | } 39 | } 40 | 41 | // Read settings file 42 | const data = bplist.parseBuffer(fs.readFileSync(plistPath)); 43 | 44 | if (data && data[0] && data[0].syncfolder) { 45 | settingsPath = untildify(data[0].syncfolder); 46 | } 47 | 48 | return `${settingsPath}/Alfred.alfredpreferences`; 49 | } 50 | } 51 | 52 | export default new Utils(); 53 | -------------------------------------------------------------------------------- /test/feedback.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | 4 | import { hugo } from './helpers/init'; 5 | import * as mock from './helpers/mock'; 6 | 7 | const backupConsoleLog = console.log; 8 | const backupConsoleError = console.error; 9 | 10 | test.beforeEach(() => { 11 | mock.date(); 12 | }); 13 | 14 | test.serial('feedback without checking for updates', async (t) => { 15 | const consoleStub = sinon.stub(console, 'log'); 16 | 17 | const h = hugo({ 18 | checkUpdates: false, 19 | }); 20 | 21 | h.rerun = 3.2; 22 | h.items.push({ 23 | title: 'foo', 24 | }); 25 | h.variables.foo = 'bar'; 26 | 27 | await h.feedback(); 28 | 29 | // Check output 30 | t.true(consoleStub.calledOnce); 31 | t.snapshot(consoleStub.getCall(0).args[0]); 32 | 33 | // Check if state has been reset 34 | t.is(h.items.length, 0); 35 | t.deepEqual(h.variables, {}); 36 | t.falsy(h.rerun); 37 | }); 38 | 39 | test.serial('check for updates on feedback', async (t) => { 40 | const consoleStub = sinon.stub(console, 'log'); 41 | 42 | const h = hugo({ 43 | updateSource: 'packal', 44 | updateNotification: false, 45 | }); 46 | 47 | const request = mock.packal(1); 48 | 49 | // Check feedback 50 | await h.feedback(); 51 | 52 | // Check request 53 | // t.true(request.isDone()); 54 | 55 | // Feedback again, should not trigger updates 56 | await h.feedback(); 57 | 58 | t.true(consoleStub.calledTwice); 59 | t.is(consoleStub.getCall(0).args[0], consoleStub.getCall(1).args[0]); 60 | t.snapshot(consoleStub.getCall(0).args[0]); 61 | }); 62 | 63 | test.afterEach.always(() => { 64 | console.log = backupConsoleLog; 65 | console.error = backupConsoleError; 66 | }); 67 | 68 | test.after.always(() => { 69 | mock.cleanAll(); 70 | }); 71 | -------------------------------------------------------------------------------- /docs/cache.md: -------------------------------------------------------------------------------- 1 | # Cache 2 | 3 | As Hugo is a framework for building [script filters](https://www.alfredapp.com/help/workflows/inputs/script-filter/), you often get to deal with dynamic data. [Fetching](./fetch.md) and processing it can take quite some time which means users have to wait each time they run your workflow. 4 | 5 | To make your workflow more responsive, Hugo initialises a [cache](https://www.npmjs.com/package/@cloudstek/cache) for you to use in your workflow. It's a simple key/value store stored as JSON in your workflow cache directory. Each item has a TTL (Time To Live) of 1 hour by default, after that time it will be removed from the cache. You can override this TTL or even disable it when setting a cache value. Disabling the TTL means it will be available forever so it acts as a normal key/value store. 6 | 7 | If you want to store configuration details for your workflow, please do not use the cache but use the [config](./config) instead. It has the same API but is stored in a different directory and has no TTL set by default, making it act as a proper key/value store. 8 | 9 | If you deal with processing local files a lot, please also see the [file cache](./file-cache.md) :star: 10 | 11 | For more details about [Cache](https://www.npmjs.com/package/@cloudstek/cache), please see: https://www.npmjs.com/package/@cloudstek/cache. 12 | 13 | ### Example 14 | 15 | ```js 16 | import { Hugo } from 'alfred-hugo'; 17 | 18 | const hugo = new Hugo(); 19 | 20 | // Store 'foo' with value 'bar' for 1 hour 21 | hugo.cache.set('foo', 'bar'); 22 | 23 | // Store 'pie' with value 'apple' for 5 seconds 24 | hugo.cache.set('pie', 'apple', 5); 25 | 26 | hugo.cache.has('pie'); // true 27 | hugo.cache.get('pie'); // apple 28 | 29 | // Wait 5 seconds. ZzzZzZzZZz 30 | // sleep(5000); 31 | 32 | hugo.cache.has('pie'); // false 33 | hugo.cache.get('pie'); // undefined 34 | ``` 35 | 36 | -------------------------------------------------------------------------------- /docs/updates.md: -------------------------------------------------------------------------------- 1 | # Updates 2 | 3 | If your workflow has been published on NPM or Packal, Hugo can automatically notifiy the user if there is an update available. 4 | 5 | There are two ways to notify the user of an available update, with a notification and with an item on the bottom of the list. You're free to choose either one or both. If you disable both methods, you effectively disable checking for updates. 6 | 7 | *Please note that support for Packal is deprecated and may be removed in future versions.* 8 | 9 | ### Update item option 10 | 11 | When the item is activated, it will set a variable named `task` (`{var:task}` in Alfred) with the value `wfUpdate`. The argument is set to the URL where the update can be found, either on Packal or NPM depending on `updateSource`. You can link the output to an [Open URL](https://www.alfredapp.com/help/workflows/actions/open-url/) action so a browser opens when the user selects the update item. 12 | 13 | See [alfred-atom](https://github.com/Cloudstek/alfred-atom) for example which uses the Conditional element in Alfred 4. 14 | 15 | ![Opening a browser for updates](../media/update-action.png) 16 | 17 | ### Examples 18 | 19 | #### Defaults 20 | 21 | ```js 22 | import { Hugo } from 'alfred-hugo'; 23 | 24 | const hugo = new Hugo({ 25 | checkUpdates: true, // Check for updates! 26 | updateInterval: moment.duration(1, 'day'), // Check for updates once a day 27 | updateItem: true, // Show an item on the bottom of the list 28 | updateNotification: true, // Show a notification 29 | updateSource: 'npm' // Check NPM for updates 30 | }); 31 | ``` 32 | 33 | #### Disable updates 34 | 35 | ```js 36 | import { Hugo } from 'alfred-hugo'; 37 | 38 | const hugo = new Hugo({ 39 | checkUpdates: false 40 | }); 41 | 42 | // Or (but not recommended) 43 | 44 | const hugo = new Hugo({ 45 | updateItem: false, 46 | updateNotification: false 47 | }); 48 | ``` 49 | 50 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | In this document you'll find everything you need to know to start contributing. Your ideas, bug reports and code are all welcome! By following these guidelines you make it a lot easier for maintainers and the community to help you with your contribution. 4 | 5 | **By participating, you are expected to have read, understand and respect the [code of conduct](CODE_OF_CONDUCT.md).** 6 | 7 | ## Submitting a bug report 8 | 9 | * **Search existing issues.** There might already be issues related to your problem. In case of closed issues that match your problem, reply to them and explain what issues you are still having to re-open the issue. 10 | * **Make sure the bug is related to Hugo** and not to any of the third-party libraries. 11 | * **Use a clear and descriptive title** for the issue to identify the problem. 12 | * **Include short and documented examples** of code that cause the unexpected behaviour. *Do not* include your whole application and expect people to fix the problem for you. 13 | 14 | ## Contributing code 15 | 16 | Want to lend a hand but not sure where to start? Check out the [list of open issues](https://github.com/Cloudstek/alfred-hugo/issues) to see if there's an issue you'd like to work on. 17 | 18 | The best way to start contributing code is to fork the repository, make your changes and issue a [pull request](https://github.com/Cloudstek/alfred-hugo/pulls). To make sure your pull request can be accepted without any problems, check the list below: 19 | 20 | * **Write and run [tests](github.com/avajs/ava)** to ensure everything is working as expected. 21 | * **Use [Gulp](http://gulpjs.com) to [lint](http://eslint.org) and [transpile](https://babeljs.io) your code.** 22 | * **Include additional (dev)dependencies** in package.json using the `npm install` `save` and `save-dev` flags. 23 | * **Update the README** to reflect your changes. 24 | * Add yourself to the list of [contributors](README.md#contributors) :+1: 25 | 26 | -------------------------------------------------------------------------------- /src/bin/unlink.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import path from 'path'; 3 | import fs from 'fs-extra'; 4 | import glob from 'glob'; 5 | import plist from 'plist'; 6 | import semver from 'semver'; 7 | import readPkg from 'read-pkg-up'; 8 | import del from 'del'; 9 | import utils from '../utils'; 10 | 11 | if (process.getuid && process.getuid() === 0) { 12 | console.error('You cannot run hugo-unlink as root.'); 13 | process.exit(1); 14 | } 15 | 16 | // Get alfred version 17 | const apps = glob.sync('/Applications/Alfred?( )+([0-9]).app'); 18 | 19 | for (const app of apps) { 20 | const plistPath = path.join(app, 'Contents', 'Info.plist'); 21 | 22 | if (!utils.fileExists(plistPath)) { 23 | continue; 24 | } 25 | 26 | try { 27 | const appInfo = plist.parse(fs.readFileSync(plistPath, { encoding: 'utf8' })); 28 | const version = appInfo.CFBundleShortVersionString; 29 | 30 | if (!version || !semver.valid(semver.coerce(version))) { 31 | continue; 32 | } 33 | 34 | const prefsDir = utils.resolveAlfredPrefs(version); 35 | const workflowsDir = path.join(prefsDir, 'workflows'); 36 | 37 | // Read package.json 38 | readPkg() 39 | .then(({ packageJson: pkg }) => { 40 | const dest = path.join(workflowsDir, pkg.name.replace('/', '-')); 41 | 42 | // Skip if destination does not exist 43 | if (fs.pathExistsSync(dest) === false) { 44 | return; 45 | } 46 | 47 | const destStat = fs.lstatSync(dest); 48 | 49 | // Skip if destination is not a symbolic link 50 | if (destStat.isSymbolicLink() === false) { 51 | console.debug('Destination is not a symbolic link, skipping.'); 52 | return; 53 | } 54 | 55 | del.sync(dest, { force: true }); 56 | }); 57 | } catch (err) {} 58 | } 59 | -------------------------------------------------------------------------------- /test/snapshots/output.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/output.ts` 2 | 3 | The actual snapshot is saved in `output.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## items only 8 | 9 | > Snapshot 1 10 | 11 | { 12 | items: [ 13 | { 14 | title: 'foo', 15 | }, 16 | { 17 | subtitle: 'foo', 18 | title: 'bar', 19 | }, 20 | { 21 | subtitle: 'bleep', 22 | title: 'boop', 23 | }, 24 | ], 25 | rerun: undefined, 26 | variables: {}, 27 | } 28 | 29 | ## items with variables 30 | 31 | > Snapshot 1 32 | 33 | { 34 | items: [ 35 | { 36 | title: 'Test 1', 37 | variables: { 38 | foo: 'bar', 39 | }, 40 | }, 41 | { 42 | arg: 'foobar', 43 | title: 'Test 2', 44 | variables: { 45 | bar: 'foo', 46 | }, 47 | }, 48 | ], 49 | rerun: undefined, 50 | variables: { 51 | bloop: 'bleep', 52 | flabby: 'flop', 53 | flooble: 'flab', 54 | }, 55 | } 56 | 57 | ## rerun parameter 58 | 59 | > Snapshot 1 60 | 61 | { 62 | items: [ 63 | { 64 | title: 'foo', 65 | }, 66 | ], 67 | rerun: 1.4, 68 | variables: { 69 | foo: 'bar', 70 | }, 71 | } 72 | 73 | ## variables and items combined 74 | 75 | > Snapshot 1 76 | 77 | { 78 | items: [ 79 | { 80 | title: 'foo', 81 | }, 82 | ], 83 | rerun: undefined, 84 | variables: { 85 | foo: 'bar', 86 | }, 87 | } 88 | 89 | ## variables only 90 | 91 | > Snapshot 1 92 | 93 | { 94 | items: [], 95 | rerun: undefined, 96 | variables: { 97 | bleep: 'bloop', 98 | boop: { 99 | tap: 'top', 100 | }, 101 | }, 102 | } 103 | -------------------------------------------------------------------------------- /src/file-cache.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from '@cloudstek/cache'; 2 | import { ICacheOptions } from '@cloudstek/cache'; 3 | import Crypto from 'crypto'; 4 | import { EventEmitter } from 'events'; 5 | import fs from 'fs-extra'; 6 | 7 | import utils from './utils'; 8 | import { FileCacheEventEmitter } from './types'; 9 | 10 | /** 11 | * File cache. 12 | * 13 | * This allows you to read and process the data once, then storing it in cache until the file has changed again. 14 | */ 15 | export class FileCache extends (EventEmitter as new() => FileCacheEventEmitter) { 16 | private readonly filePath: string; 17 | private readonly cache: Cache; 18 | 19 | /** 20 | * FileCache constructor 21 | * 22 | * @param filePath File to process and check for changes 23 | * @param options Cache options 24 | * 25 | * @constructor 26 | */ 27 | constructor(filePath: string, options: ICacheOptions) { 28 | super(); 29 | 30 | this.filePath = filePath; 31 | 32 | // Initialize cache store for this file 33 | options.name = Crypto.createHash('sha1').update(filePath).digest('hex') + '.json'; 34 | options.ttl = options.ttl || false; 35 | 36 | this.cache = new Cache(options); 37 | } 38 | 39 | /** 40 | * Get (cached) contents. 41 | * 42 | * Emits the "change" event with the cache instance, file and hash of that file when the file has been changed 43 | * or expired from the cache. 44 | */ 45 | public get(): any { 46 | if (utils.fileExists(this.filePath) === false) { 47 | return null; 48 | } 49 | 50 | // Get file fstat 51 | const stat = fs.statSync(this.filePath); 52 | 53 | if (this.cache.has('mtime') === false || this.cache.get('mtime') !== stat.mtimeMs) { 54 | this.emit('change', this.cache, fs.readFileSync(this.filePath, 'utf8')); 55 | 56 | this.cache.set('mtime', stat.mtimeMs); 57 | this.cache.commit(); 58 | 59 | return this.cache.all(); 60 | } 61 | 62 | return this.cache.all(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/action.ts: -------------------------------------------------------------------------------- 1 | export class Action { 2 | private readonly actions: Action[]; 3 | private readonly names: string[]; 4 | private readonly callback?: (args: string[]) => void; 5 | 6 | public constructor(name: string|string[], callback?: (args: string[]) => void) { 7 | if (typeof name === 'string') { 8 | name = [name]; 9 | } 10 | 11 | this.names = name; 12 | this.callback = callback; 13 | this.actions = []; 14 | 15 | if (this.names.length === 0) { 16 | throw new Error('Action has no name or aliases.'); 17 | } 18 | 19 | for (const n of this.names) { 20 | if (n.trim().length === 0) { 21 | throw new Error('Action name or alias cannot be empty an empty string.'); 22 | } 23 | } 24 | } 25 | 26 | /** 27 | * Run a callback when script argument matches keyword. Callback wil have remaining arguments as argument. 28 | * 29 | * @example node index.js firstaction secondaction "my query" 30 | * 31 | * @param name Action name and optionally aliases 32 | * @param callback Callback to execute when query matches action name 33 | * 34 | * @return Action 35 | */ 36 | public action(name: string|string[], callback: (args: string[]) => void): Action { 37 | const action = new Action(name, callback); 38 | 39 | this.actions.push(action); 40 | 41 | return action; 42 | } 43 | 44 | public run(args: string[]): boolean { 45 | if (args.length === 0) { 46 | return false; 47 | } 48 | 49 | if (this.names.includes(args[0])) { 50 | const subArgs = args.slice(1); 51 | 52 | // Check sub actions first 53 | for (const action of this.actions) { 54 | if (action.run(subArgs) === true) { 55 | return true; 56 | } 57 | } 58 | 59 | // Run self 60 | if (this.callback) { 61 | this.callback(subArgs); 62 | return true; 63 | } 64 | } 65 | 66 | return false; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /docs/items.md: -------------------------------------------------------------------------------- 1 | # Items & Variables 2 | 3 | Hugo is mainly intended as a framework for writing [script filters](https://www.alfredapp.com/help/workflows/inputs/script-filter/), therefore you have direct access to the items that will be sent to Alfred and displayed. The same goes for variables and the rerun parameter, all part of the output sent to Alfred. 4 | 5 | To learn more about items, variables and the rerun parameter, please see the Alfred documentation at https://www.alfredapp.com/help/workflows/inputs/script-filter/json 6 | 7 | ### Examples 8 | 9 | #### Items 10 | 11 | ```js 12 | import { Hugo } from 'alfred-hugo'; 13 | 14 | const hugo = new Hugo(); 15 | 16 | // Add single item 17 | hugo.items.push({ 18 | title: 'Foo', 19 | subtitle: 'Bar', 20 | arg: 'foobar' 21 | }); 22 | 23 | // Add multiple items 24 | const items = [ 25 | { 26 | title: 'Foo', 27 | subtitle: 'Bar', 28 | arg: 'foobar' 29 | }, 30 | { 31 | title: 'Apple', 32 | subtitle: 'Pie', 33 | arg: 'omnomnom' 34 | } 35 | ]; 36 | 37 | hugo.items = hugo.items.concat(items); 38 | 39 | // Flush output buffer 40 | hugo.feedback(); 41 | ``` 42 | 43 | #### Variables 44 | 45 | Besides item variables you can also set global/session variables (please see the [documentation](https://www.alfredapp.com/help/workflows/inputs/script-filter/json/#variables)). Much like items, the variables are directly exposed as an object. 46 | 47 | ```js 48 | import { Hugo } from 'alfred-hugo'; 49 | 50 | const hugo = new Hugo(); 51 | 52 | // Set a variable 53 | hugo.variables.foo = 'bar'; 54 | 55 | // Set (override) multiple 56 | hugo.variables = { 57 | foo: 'bar', 58 | apple: 'pie' 59 | }; 60 | ``` 61 | 62 | #### Rerun 63 | 64 | > Scripts can be set to re-run automatically after an interval using the 'rerun' key with a value of 0.1 to 5.0 seconds. The script will only be re-run if the script filter is still active and the user hasn't changed the state of the filter by typing and triggering a re-run. 65 | 66 | ```js 67 | import { Hugo } from 'alfred-hugo'; 68 | 69 | const hugo = new Hugo(); 70 | 71 | // Will re-run the script each 650ms after output 72 | hugo.rerun = 0.65; 73 | ``` 74 | 75 | -------------------------------------------------------------------------------- /test/file-cache.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import test from 'ava'; 4 | 5 | import { hugo } from './helpers/init'; 6 | import * as mock from './helpers/mock'; 7 | 8 | test.serial('process file and cache it', (t) => { 9 | const h = hugo(); 10 | const tmpFile = mock.file(); 11 | 12 | // Create file 13 | fs.writeFileSync(tmpFile, 'Hello world!'); 14 | 15 | // Get FileCache instance 16 | const cachedFile = h.cacheFile(tmpFile); 17 | 18 | // Listen to change event to process data 19 | cachedFile.once('change', (cache, file) => { 20 | t.is(cache.constructor.name, 'Cache'); 21 | t.is('Hello world!', file); 22 | 23 | cache.set('hello', 'world!'); 24 | }); 25 | 26 | // Fetch data 27 | let data = cachedFile.get(); 28 | 29 | // Verify data 30 | t.is(typeof data, 'object'); 31 | t.is(data.hello, 'world!'); 32 | 33 | // Listen to change event (which should not be emitted now) 34 | cachedFile.once('change', () => { 35 | t.fail('Data has not been cached.'); 36 | }); 37 | 38 | // Fetch data again and verify it 39 | t.deepEqual(cachedFile.get(), data); 40 | 41 | cachedFile.removeAllListeners(); 42 | 43 | // Listen to change event to process data 44 | cachedFile.once('change', (cache, file) => { 45 | t.is(cache.constructor.name, 'Cache'); 46 | t.is('Foobar', file); 47 | 48 | cache.set('foo', 'bar'); 49 | }); 50 | 51 | // Change file to trigger change on next get 52 | fs.writeFileSync(tmpFile, 'Foobar'); 53 | 54 | // Fetch data 55 | data = cachedFile.get(); 56 | 57 | // Verify data 58 | t.is(typeof data, 'object'); 59 | t.is(data.foo, 'bar'); 60 | }); 61 | 62 | test.serial('process non-existing file', (t) => { 63 | const h = hugo(); 64 | 65 | // Get FileCache instance 66 | const cachedFile = h.cacheFile(path.resolve(__dirname, 'idontexist.txt')); 67 | 68 | // Listen to change event to process data 69 | cachedFile.on('change', () => { 70 | t.fail('File does not exist and this should not be called.'); 71 | }); 72 | 73 | // Fetch data 74 | const data = cachedFile.get(); 75 | 76 | // Verify data 77 | t.falsy(data); 78 | }); 79 | -------------------------------------------------------------------------------- /test/output.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import { hugo } from './helpers/init'; 4 | 5 | test('items only', (t) => { 6 | const h = hugo(); 7 | 8 | h.items.push( 9 | { 10 | title: 'foo', 11 | }, 12 | { 13 | title: 'bar', 14 | subtitle: 'foo', 15 | }, 16 | { 17 | title: 'boop', 18 | subtitle: 'bleep', 19 | }, 20 | ); 21 | 22 | t.snapshot(h.output); 23 | }); 24 | 25 | test('variables only', (t) => { 26 | const h = hugo(); 27 | 28 | h.variables = { 29 | bleep: 'bloop', 30 | boop: { 31 | tap: 'top', 32 | }, 33 | }; 34 | 35 | t.snapshot(h.output); 36 | }); 37 | 38 | test('variables and items combined', (t) => { 39 | const h = hugo(); 40 | 41 | h.variables.foo = 'bar'; 42 | h.items.push({ 43 | title: 'foo', 44 | }); 45 | 46 | t.snapshot(h.output); 47 | }); 48 | 49 | test('items with variables', (t) => { 50 | const h = hugo(); 51 | 52 | h.items.push( 53 | { 54 | title: 'Test 1', 55 | variables: { 56 | foo: 'bar', 57 | }, 58 | }, 59 | { 60 | title: 'Test 2', 61 | arg: 'foobar', 62 | variables: { 63 | bar: 'foo', 64 | }, 65 | }, 66 | ); 67 | 68 | h.variables = { 69 | bloop: 'bleep', 70 | flooble: 'flab', 71 | flabby: 'flop', 72 | }; 73 | 74 | t.snapshot(h.output); 75 | }); 76 | 77 | test('rerun parameter', (t) => { 78 | const h = hugo(); 79 | 80 | h.rerun = 1.4; 81 | h.variables.foo = 'bar'; 82 | h.items.push({ 83 | title: 'foo', 84 | }); 85 | 86 | t.snapshot(h.output); 87 | }); 88 | 89 | test('invalid rerun parameter', (t) => { 90 | const h = hugo(); 91 | 92 | h.rerun = 10.5; 93 | h.variables.foo = 'bar'; 94 | h.items.push({ 95 | title: 'foo', 96 | }); 97 | 98 | try { 99 | const contents = h.output; 100 | 101 | t.falsy(contents); 102 | t.fail(); 103 | } catch (e) { 104 | t.pass(); 105 | } 106 | }); 107 | -------------------------------------------------------------------------------- /test/meta/workflow.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | 4 | import { hugo } from '../helpers/init'; 5 | 6 | const backupConsoleError = console.error; 7 | 8 | test.serial('valid version', (t) => { 9 | const h = hugo(); 10 | 11 | process.env.alfred_workflow_version = '3.0.0'; 12 | 13 | // Check version number 14 | t.is(typeof h.workflowMeta, 'object'); 15 | t.is(h.workflowMeta.version, '3.0.0'); 16 | }); 17 | 18 | test.serial('single digit version', (t) => { 19 | const h = hugo(); 20 | 21 | process.env.alfred_workflow_version = '3'; 22 | 23 | // Check version number 24 | t.is(typeof h.workflowMeta, 'object'); 25 | t.is(h.workflowMeta.version, '3.0.0'); 26 | }); 27 | 28 | test.serial('two digit version', (t) => { 29 | const h = hugo(); 30 | 31 | process.env.alfred_workflow_version = '3.0'; 32 | 33 | // Check version number 34 | t.is(typeof h.workflowMeta, 'object'); 35 | t.is(h.workflowMeta.version, '3.0.0'); 36 | }); 37 | 38 | test.serial('no version', (t) => { 39 | const consoleStub = sinon.stub(console, 'error'); 40 | const h = hugo(); 41 | 42 | process.env.alfred_debug = '0'; 43 | delete process.env.alfred_workflow_version; 44 | 45 | // Check version number 46 | t.is(typeof h.workflowMeta, 'object'); 47 | t.falsy(h.workflowMeta.version); 48 | t.false(consoleStub.called); 49 | 50 | // Check if debug message is output 51 | process.env.alfred_debug = '1'; 52 | 53 | t.is(typeof h.workflowMeta, 'object'); 54 | t.falsy(h.workflowMeta.version); 55 | t.true(consoleStub.calledWith(sinon.match('Invalid workflow version: undefined'))); 56 | }); 57 | 58 | test.serial('invalid version', (t) => { 59 | const consoleStub = sinon.stub(console, 'error'); 60 | const h = hugo(); 61 | 62 | process.env.alfred_debug = '0'; 63 | process.env.alfred_workflow_version = 'foobar'; 64 | 65 | // Check version number 66 | t.is(typeof h.workflowMeta, 'object'); 67 | t.falsy(h.workflowMeta.version); 68 | t.false(consoleStub.called); 69 | 70 | // Check if debug message is output 71 | process.env.alfred_debug = '1'; 72 | 73 | t.is(typeof h.workflowMeta, 'object'); 74 | t.falsy(h.workflowMeta.version); 75 | t.true(consoleStub.calledWith(sinon.match('Invalid workflow version: foobar'))); 76 | }); 77 | 78 | test.afterEach.always(() => { 79 | console.error = backupConsoleError; 80 | }); 81 | -------------------------------------------------------------------------------- /test/helpers/init.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-arrow/prefer-arrow-functions,@typescript-eslint/camelcase */ 2 | import { Cache } from '@cloudstek/cache'; 3 | import crypto from 'crypto'; 4 | import path from 'path'; 5 | import moment from 'moment'; 6 | import fs from 'fs-extra'; 7 | import nock from 'nock'; 8 | import sinon from 'sinon'; 9 | 10 | import { Updater, Hugo, HugoOptions } from '../../src'; 11 | 12 | export function setAlfredEnv(): void { 13 | process.env.alfred_version = '4.0.0'; 14 | process.env.alfred_workflow_version = '1.0.0'; 15 | process.env.alfred_workflow_bundleid = 'my.work.flow'; 16 | process.env.alfred_preferences = path.join('build', 'cache', crypto.randomBytes(8).toString('hex')); 17 | process.env.alfred_workflow_data = path.join('build', 'cache', crypto.randomBytes(8).toString('hex')); 18 | process.env.alfred_workflow_cache = path.join('build', 'cache', crypto.randomBytes(8).toString('hex')); 19 | process.env.alfred_debug = '0'; 20 | 21 | fs.ensureDirSync(process.env.alfred_preferences); 22 | fs.ensureDirSync(process.env.alfred_workflow_data); 23 | fs.ensureDirSync(process.env.alfred_workflow_cache); 24 | 25 | // Set up fake home dir 26 | process.env.HOME = path.join('build', 'cache', crypto.randomBytes(8).toString('hex')); 27 | } 28 | 29 | export function hugo(options?: HugoOptions): Hugo { 30 | setAlfredEnv(); 31 | 32 | // Disable real HTTP requests with nock 33 | nock.disableNetConnect(); 34 | 35 | if (!options) { 36 | options = { 37 | checkUpdates: false, 38 | }; 39 | } 40 | 41 | // Init hugo 42 | const h = new Hugo(options); 43 | 44 | // Init another Hugo that has no stubs and stuff 45 | const originalHugo = new Hugo(options); 46 | 47 | sinon.stub(h, 'workflowMeta').get(() => { 48 | const workflowMeta = originalHugo.workflowMeta; 49 | 50 | // Give workflow icon a fixed path 51 | workflowMeta.icon = '/foo/bar/icon.png'; 52 | 53 | return workflowMeta; 54 | }); 55 | 56 | return h; 57 | } 58 | 59 | export function updater(cacheTtl?: number | moment.Duration): Updater { 60 | setAlfredEnv(); 61 | 62 | // Disable real HTTP requests with nock 63 | nock.disableNetConnect(); 64 | 65 | const cache = new Cache({ 66 | dir: process.env.alfred_workflow_cache, 67 | }); 68 | 69 | return new Updater(cache, cacheTtl || moment.duration(1, 'hour')); 70 | } 71 | -------------------------------------------------------------------------------- /docs/metadata.md: -------------------------------------------------------------------------------- 1 | # Metadata 2 | 3 | Alfred exposes some useful metadata to the script using environment variables. This for example gives you information about Alfred's version, prefrerences and more. Alfred also exposes metadata about the workflow. 4 | 5 | Hugo makes it easy for you to access both Alfred's metadata and your workflow metadata. 6 | 7 | ## Alfred metadata 8 | 9 | Alfred metadata can be accessed using the `alfredMeta` property of Hugo and is an object containing the following items: 10 | 11 | ```json 12 | { 13 | debug: true, // True when debug panel is opened 14 | preferences: // Path to Alfred preferences file, 15 | preferencesLocalHash: // Contains hash from alfred_preferences_localhash, 16 | theme: // Theme file name, 17 | themeBackground: // Theme background colour, 18 | themeSelectionBackground: // Colour of the selected result, 19 | themeSubtext: // Contains value from alfred_theme_subtext, 20 | themeFile: // Absolute path to theme file (if found), 21 | version: // Alfred version 22 | } 23 | ``` 24 | 25 | For background information, see: https://www.alfredapp.com/help/workflows/script-environment-variables 26 | 27 | ## Workflow metadata 28 | 29 | Workflow metadata can be accessed through the `workflowMeta` property of Hugo. 30 | 31 | ```json 32 | { 33 | bundleId: // The bundle ID of the current running workflow, 34 | cache: // Path to workflow cache directory, 35 | data: // Path to workflow data directory, 36 | icon: // Path to workflow icon, 37 | name: // Workflow name, 38 | uid: // Unique ID of the workflow, 39 | version: // Workflow version 40 | } 41 | ``` 42 | 43 | ## Theme data 44 | 45 | To save you some effort when you really want to blend in, Hugo has an easy way of loading the theme file for you (if it was found). You can access the active theme data using the `alfredTheme` property of Hugo. 46 | 47 | ```js 48 | import { Hugo } from 'alfred-hugo'; 49 | 50 | const hugo = new Hugo(); 51 | 52 | const themeData = hugo.alfredTheme; 53 | 54 | // themeData will contain the parsed JSON contents of the active Alfred theme 55 | ``` 56 | 57 | ## Example 58 | 59 | ```js 60 | import { Hugo } from 'alfred-hugo'; 61 | 62 | const hugo = new Hugo(); 63 | 64 | console.log(`Alfred version ${hugo.alfredMeta.version}`); 65 | console.log(`Workflow ${hugo.workflowMeta.name} version ${hugo.workflowMeta.version}`); 66 | 67 | // Alfred version 4.0.0 68 | // Workflow alfred-foobar version 1.0.0 69 | ``` 70 | 71 | -------------------------------------------------------------------------------- /src/bin/link.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import path from 'path'; 3 | import fs from 'fs-extra'; 4 | import glob from 'glob'; 5 | import plist from 'plist'; 6 | import semver from 'semver'; 7 | import readPkg from 'read-pkg-up'; 8 | import del from 'del'; 9 | import utils from '../utils'; 10 | 11 | if (process.getuid && process.getuid() === 0) { 12 | console.error('You cannot run hugo-link as root.'); 13 | process.exit(1); 14 | } 15 | 16 | // Get alfred version 17 | const apps = glob.sync('/Applications/Alfred?( )+([0-9]).app'); 18 | 19 | for (const app of apps) { 20 | const plistPath = path.join(app, 'Contents', 'Info.plist'); 21 | 22 | if (!utils.fileExists(plistPath)) { 23 | continue; 24 | } 25 | 26 | try { 27 | const appInfo = plist.parse(fs.readFileSync(plistPath, { encoding: 'utf8' })); 28 | const version = appInfo.CFBundleShortVersionString; 29 | 30 | if (!version || !semver.valid(semver.coerce(version))) { 31 | continue; 32 | } 33 | 34 | const prefsDir = utils.resolveAlfredPrefs(version); 35 | const workflowsDir = path.join(prefsDir, 'workflows'); 36 | 37 | // Read package.json 38 | readPkg() 39 | .then(({ packageJson: pkg, path: pkgPath }) => { 40 | const src = path.dirname(pkgPath); 41 | const dest = path.join(workflowsDir, pkg.name.replace('/', '-')); 42 | 43 | if (fs.pathExistsSync(dest)) { 44 | const destStat = fs.lstatSync(dest); 45 | 46 | // Skip link creation if destination is a directory, not a symlink 47 | if (destStat.isDirectory()) { 48 | console.debug('Destination is a directory, skipping.'); 49 | return; 50 | } 51 | 52 | // Skip if destination exists but is not a directory or symlink 53 | if (destStat.isSymbolicLink() === false) { 54 | console.debug('Destination exists but is neither a directory or symlink, skipping.'); 55 | return; 56 | } 57 | 58 | // Skip link creation if already linked 59 | if (fs.realpathSync(dest) === src) { 60 | console.debug('Link already exists, skipping.'); 61 | return; 62 | } 63 | 64 | // Remove existing symlink 65 | del.sync(dest, { force: true }); 66 | } 67 | 68 | // Create symlink 69 | fs.ensureSymlinkSync(src, dest); 70 | }) 71 | .catch((err) => { 72 | console.error(err); 73 | }); 74 | } catch (err) {} 75 | } 76 | -------------------------------------------------------------------------------- /test/fetch.ts: -------------------------------------------------------------------------------- 1 | import Test, { TestInterface } from 'ava'; 2 | import nock from 'nock'; 3 | import crypto from 'crypto'; 4 | 5 | import { hugo } from './helpers/init'; 6 | import { TestContext } from './helpers/types'; 7 | import * as mock from './helpers/mock'; 8 | 9 | const test = Test as TestInterface; 10 | 11 | test.beforeEach((t) => { 12 | t.context.url = 'http://foo.bar'; 13 | t.context.urlHash = crypto.createHash('md5').update(t.context.url).digest('hex'); 14 | 15 | // Mock requests 16 | nock(t.context.url) 17 | .get('/') 18 | .once() 19 | .reply(200, {message: 'hello' }); 20 | 21 | nock(t.context.url) 22 | .get('/') 23 | .once() 24 | .reply(200, {message: 'world'}); 25 | 26 | mock.date(); 27 | }); 28 | 29 | test.serial('fetch uncached', async (t) => { 30 | const h = hugo(); 31 | 32 | // Fetch with caching implicitely disabled 33 | t.deepEqual(await h.fetch(t.context.url), { message: 'hello' }); 34 | t.false(h.cache.has(t.context.urlHash)); 35 | 36 | // Fetch with caching explicitely disabled 37 | t.deepEqual(await h.fetch(t.context.url, null, false), { message: 'world' }); 38 | t.false(h.cache.has(t.context.urlHash)); 39 | }); 40 | 41 | test.serial('fetch cached', async (t) => { 42 | const h = hugo(); 43 | 44 | // Fetch cached with empty cache 45 | t.false(h.cache.has(t.context.url)); 46 | t.deepEqual(await h.fetch(t.context.url, null, 300), { message: 'hello' }); 47 | t.deepEqual(h.cache.get(t.context.urlHash), { message: 'hello' }); 48 | 49 | // Fetch cached with warm cache and assert we have the right output. 50 | // Nock is set to only return 'hello' once. So if the request is not cached, it would return 'world'. 51 | t.deepEqual(await h.fetch(t.context.url, null, 300), { message: 'hello' }); 52 | t.deepEqual(h.cache.get(t.context.urlHash), { message: 'hello' }); 53 | 54 | // Let the cache expire 55 | mock.forwardTime(1, 'hour'); 56 | 57 | // Assert cache is expired 58 | t.false(h.cache.has(t.context.url)); 59 | 60 | // Fetch cached with empty cache one more time and assert that the request is done. 61 | // Nock is set to only return 'hello' once, which we received before. This time it should return 'world' to complete 62 | // our sentence. If not, this would indicate the request is still cached somehow. 63 | t.deepEqual(await h.fetch(t.context.url, null, 300), { message: 'world' }); 64 | t.deepEqual(h.cache.get(t.context.urlHash), { message: 'world' }); 65 | 66 | // Fetch cached with a warm cache again. 67 | t.deepEqual(await h.fetch(t.context.url, null, 300), { message: 'world' }); 68 | t.deepEqual(h.cache.get(t.context.urlHash), { message: 'world' }); 69 | }); 70 | 71 | test.afterEach.always(() => { 72 | mock.cleanAll(); 73 | }); 74 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Duration } from 'moment'; 2 | import StrictEventEmitter from 'strict-event-emitter-types'; 3 | import { EventEmitter } from 'events'; 4 | import { Cache } from '@cloudstek/cache'; 5 | 6 | export enum IconType { 7 | fileIcon = 'fileicon', 8 | fileType = 'filetype', 9 | } 10 | 11 | export enum ItemType { 12 | default = 'default', 13 | file = 'file', 14 | fileSkipCheck = 'file:skipcheck', 15 | } 16 | 17 | export enum UpdateSource { 18 | NPM = 'npm' as any, 19 | Packal = 'packal' as any, 20 | } 21 | 22 | export interface HugoOptions { 23 | checkUpdates?: boolean; 24 | updateSource?: UpdateSource | string; 25 | updateInterval?: number | Duration; 26 | updateNotification?: boolean; 27 | updateItem?: boolean; 28 | } 29 | 30 | export interface WorkflowMeta { 31 | name?: string; 32 | version?: string; 33 | uid?: string; 34 | bundleId?: string; 35 | data?: string; 36 | cache?: string; 37 | icon: string; 38 | } 39 | 40 | export interface AlfredMeta { 41 | version?: string; 42 | theme?: string; 43 | themeFile?: string; 44 | themeBackground?: string; 45 | themeSelectionBackground?: string; 46 | themeSubtext?: number; 47 | preferences?: string; 48 | preferencesLocalHash?: string; 49 | debug: boolean; 50 | } 51 | 52 | export interface FilterResults { 53 | rerun?: number; 54 | variables?: { [key: string]: any }; 55 | items: Item[]; 56 | } 57 | 58 | export interface Item { 59 | uid?: string; 60 | title: string; 61 | subtitle?: string; 62 | arg?: string; 63 | icon?: ItemIcon; 64 | valid?: boolean; 65 | match?: string; 66 | autocomplete?: string; 67 | type?: ItemType; 68 | mods?: ItemModifiers; 69 | text?: ItemText; 70 | quicklookurl?: string; 71 | variables?: { [key: string]: any }; 72 | } 73 | 74 | export interface ItemIcon { 75 | type?: IconType; 76 | path: string; 77 | } 78 | 79 | export interface ItemModifiers { 80 | alt?: ItemModifier; 81 | ctrl?: ItemModifier; 82 | cmd?: ItemModifier; 83 | fn?: ItemModifier; 84 | shift?: ItemModifier; 85 | } 86 | 87 | export interface ItemModifier { 88 | valid: boolean; 89 | subtitle: string; 90 | arg: string; 91 | variables?: { [key: string]: any }; 92 | } 93 | 94 | export interface ItemText { 95 | copy?: string; 96 | largetype?: string; 97 | } 98 | 99 | export interface LatestVersion { 100 | version: string; 101 | url: string; 102 | checkedOnline: boolean; 103 | } 104 | 105 | export interface FileCacheEvents { 106 | change: (cache: Cache, file: string) => void; 107 | } 108 | 109 | export type FileCacheEventEmitter = StrictEventEmitter; 110 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alfred-hugo", 3 | "version": "3.0.1", 4 | "description": "Alfred workflow bindings for NodeJS", 5 | "author": "Maarten de Boer (https://cloudstek.nl)", 6 | "contributors": [ 7 | "Marjolein Regterschot " 8 | ], 9 | "repository": "Cloudstek/alfred-hugo", 10 | "bugs": "https://github.com/Cloudstek/alfred-hugo/issues", 11 | "main": "dist/index.js", 12 | "types": "./dist/index.d.ts", 13 | "files": [ 14 | "/dist" 15 | ], 16 | "bin": { 17 | "hugo-link": "./dist/bin/link.js", 18 | "hugo-unlink": "./dist/bin/unlink.js" 19 | }, 20 | "keywords": [ 21 | "alfred", 22 | "workflow", 23 | "macos", 24 | "mac", 25 | "osx", 26 | "util", 27 | "utility", 28 | "helper" 29 | ], 30 | "license": "BSD-2-Clause", 31 | "devDependencies": { 32 | "@types/fs-extra": "^8.0.0", 33 | "@types/glob": "^7.1.1", 34 | "@types/mockdate": "^2.0.0", 35 | "@types/node": "^13.7.0", 36 | "@types/semver": "^7.1.0", 37 | "@types/sinon": "^7.0.12", 38 | "@typescript-eslint/eslint-plugin": "^2.19.0", 39 | "@typescript-eslint/parser": "^2.19.0", 40 | "ava": "^3.2.0", 41 | "bplist-creator": "^0.0.8", 42 | "coveralls": "^3.0.4", 43 | "del-cli": "^3.0.0", 44 | "eslint": "^6.8.0", 45 | "eslint-plugin-prefer-arrow": "^1.1.7", 46 | "mockdate": "^2.0.2", 47 | "nock": "^12.0.3", 48 | "npm-run-all": "^4.1.5", 49 | "nyc": "^15.0.0", 50 | "sinon": "^9.0.1", 51 | "tslint": "^6.0.0", 52 | "typescript": "^3.7.5" 53 | }, 54 | "dependencies": { 55 | "@cloudstek/cache": "^1.0.5", 56 | "@types/node-notifier": "^6.0.0", 57 | "axios": "^0.19.2", 58 | "bplist-parser": "^0.2.0", 59 | "del": "^5.0.0", 60 | "fs-extra": "^9.0.0", 61 | "fuse.js": "^3.6.1", 62 | "glob": "^7.1.4", 63 | "moment": "^2.17.1", 64 | "node-notifier": "^6.0.0", 65 | "plist": "^3.0.1", 66 | "read-pkg-up": "^7.0.1", 67 | "semver": "^7.1.2", 68 | "strict-event-emitter-types": "^2.0.0", 69 | "untildify": "^4.0.0" 70 | }, 71 | "engines": { 72 | "node": ">=10" 73 | }, 74 | "scripts": { 75 | "clean": "npm-run-all -p clean:*", 76 | "clean:dist": "del-cli dist", 77 | "clean:test": "del-cli build", 78 | "clean:coverage": "del-cli coverage .nyc_output", 79 | "build": "npm-run-all -p clean:test clean:coverage && tsc", 80 | "watch": "npm-run-all -p clean:test clean:coverage && tsc --watch", 81 | "build:dist": "npm-run-all -s clean:dist && tsc -p tsconfig.dist.json", 82 | "test": "npm-run-all -p clean:coverage && tsc && nyc ava", 83 | "test:ci": "npm-run-all -p clean:* && tsc && nyc -s ava && nyc report --reporter=text-lcov | coveralls", 84 | "lint": "npm-run-all -l -p lint:*", 85 | "lint:src": "eslint src/**/*.ts", 86 | "lint:test": "eslint test/**/*.ts" 87 | }, 88 | "ava": { 89 | "files": [ 90 | "build/test/**/*", 91 | "!build/test/helpers/**/*" 92 | ] 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Hugo](https://cdn.rawgit.com/cloudstek/alfred-hugo/master/media/logo-hugo.svg) 2 | 3 | [![CircleCI](https://img.shields.io/circleci/build/github/Cloudstek/alfred-hugo.svg)](https://circleci.com/gh/Cloudstek/alfred-hugo) [![Coverage Status](https://coveralls.io/repos/github/Cloudstek/alfred-hugo/badge.svg?branch=master)](https://coveralls.io/github/Cloudstek/alfred-hugo?branch=master) [![Open Issues](https://img.shields.io/github/issues/Cloudstek/alfred-hugo.svg)](https://github.com/Cloudstek/alfred-hugo/issues) ![npm](https://img.shields.io/npm/dt/alfred-hugo.svg) ![GitHub](https://img.shields.io/github/license/Cloudstek/alfred-hugo.svg) ![GitHub stars](https://img.shields.io/github/stars/Cloudstek/alfred-hugo.svg) 4 | 5 | Hugo is a [script filter](https://www.alfredapp.com/help/workflows/inputs/script-filter/) framework for your Alfred workflows. It can handle fetching and caching data, configuration storage, checking for updates and much much more. But I suppose you can use it for other purposes in your workflow as well :man_shrugging: 6 | 7 | ### Highlights 8 | 9 | * Written in Typescript :star: 10 | * Well tested :thumbsup: 11 | * Built-in cache and configuration storage 12 | * Advanced filtering of items using [Fuse.js](http://fusejs.io) :mag: 13 | * Fetch (JSON) from REST API's using [Axios](https://github.com/axios/axios) :earth_americas: 14 | * Update notifications :mailbox: 15 | 16 | ## Getting started 17 | 18 | ### Prerequisites 19 | 20 | * NodeJS 10 or higher 21 | * Alfred 4 with [paid powerpack addon](https://www.alfredapp.com/powerpack/buy/) 22 | 23 | ### Installing 24 | 25 | Hugo can be installed using Yarn or NPM: 26 | 27 | ```bash 28 | $ yarn add alfred-hugo 29 | ``` 30 | 31 | ```bash 32 | $ npm install --save alfred-hugo 33 | ``` 34 | 35 | ### Writing your script filter 36 | 37 | Please see the [docs](./docs) for documentation and examples on how to use Hugo to write your script filters. 38 | 39 | ### Publishing your workflow to NPM 40 | 41 | To publish your workflow to NPM, set up the `postinstall` and `preuninstall` scripts in your `package.json` as follows to automatically add your workflow to Alfred. 42 | 43 | ```json 44 | { 45 | "name": "alfred-unicorn", 46 | "scripts": { 47 | "postinstall": "hugo-link", 48 | "preuninstall": "hugo-unlink" 49 | } 50 | } 51 | ``` 52 | 53 | People can now install your package globally like this: 54 | 55 | ```bash 56 | $ npm install -g my-alfred-package 57 | ``` 58 | 59 | ## Workflows using Hugo 60 | 61 | List of Alfred workflows using Hugo. 62 | 63 | * [alfred-atom](https://github.com/Cloudstek/alfred-atom) - Alfred workflow to browse and open Atom projects 64 | 65 | *Feel free to submit your own by opening an [issue](https://github.com/Cloudstek/alfred-hugo/issues) or submitting a [pull request](https://github.com/Cloudstek/alfred-hugo/pulls).* 66 | 67 | ## Contributing 68 | 69 | See [CONTRIBUTING](CONTRIBUTING.md) for more info about how to contribute. 70 | 71 | ## Authors 72 | 73 | * [Maarten de Boer](https://github.com/mdeboer) 74 | 75 | ### Contributors 76 | * [Marjolein Regterschot](https://github.com/rmarjolein) (Artwork) 77 | 78 | ## License 79 | 80 | BSD-2-Clause license, see [LICENSE](LICENSE). 81 | -------------------------------------------------------------------------------- /docs/actions.md: -------------------------------------------------------------------------------- 1 | # Actions 2 | 3 | Actions are an easy way to structure your workflow in-code without having to drag all kinds of nodes in to launch different script for each action. Now you can run one script and have it handle all your actions. 4 | 5 | You can use them to do anything like run programs, manipulate files, whatever. You can also use them in [script filter inputs](https://www.alfredapp.com/help/workflows/inputs/script-filter/) for example to list the latest tweets for a hashtag you enter. Also see [./items.md] for more info and examples. 6 | 7 | ### Examples 8 | 9 | ##### Simple action 10 | 11 | ```js 12 | import { Hugo } from 'alfred-hugo'; 13 | 14 | const hugo = new Hugo(); 15 | 16 | // Hello action 17 | hugo.action('hello', (query) => { 18 | console.log(`Hello ${query}!`); 19 | }); 20 | 21 | // Run matching actions 22 | hugo.run(); 23 | ``` 24 | 25 | ```sh 26 | node index.js hello world 27 | # Hello world! 28 | ``` 29 | 30 | ##### Simple action with aliases 31 | 32 | ```js 33 | import { Hugo } from 'alfred-hugo'; 34 | 35 | const hugo = new Hugo(); 36 | 37 | // Hello action 38 | hugo.action(['hi', 'hello'], (query) => { 39 | console.log(`Hello ${query}!`); 40 | }); 41 | 42 | // Run matching actions 43 | hugo.run(); 44 | ``` 45 | 46 | ```sh 47 | node index.js hello world 48 | # Hello world! 49 | 50 | node index.js hi world 51 | # Hello world! 52 | ``` 53 | 54 | ##### Action with nested sub-actions 55 | 56 | ```js 57 | import { Hugo } from 'alfred-hugo'; 58 | 59 | const hugo = new Hugo(); 60 | 61 | // List action 62 | const listAction = hugo.action('list', (query) => { 63 | console.log('Usage: list '); 64 | }); 65 | 66 | // List bikes sub-action 67 | listAction.action('bikes', (query) => { 68 | const brand = query[0] || ''; 69 | 70 | console.log(`Here be a list of ${brand} bikes.`); 71 | }); 72 | 73 | // List cars sub-action 74 | const listCarsAction = listAction.action('cars', (query) => { 75 | const brand = query[0] || ''; 76 | 77 | console.log(`Here be a list of ${brand} cars.`); 78 | }); 79 | 80 | // Sub-action of the list cars sub-action. 81 | listCarsAction.action('Porsche', (query) => { 82 | console.log('Porsche, ahh very fancy cars!'); 83 | }); 84 | 85 | // Run matching actions 86 | hugo.run(); 87 | ``` 88 | 89 | ```sh 90 | node index.js list 91 | # Usage: list 92 | 93 | node index.js list bikes Ducati 94 | # Here be a list of Ducati bikes. 95 | 96 | node index.js list cars Ferrari 97 | # Here be a list of Ferrari cars. 98 | 99 | node index.js list cars Porsche 100 | # Porsche, ahh very fancy cars! 101 | ``` 102 | 103 | ##### Simple script filter action 104 | 105 | ```js 106 | import { Hugo } from 'alfred-hugo'; 107 | 108 | const hugo = new Hugo(); 109 | 110 | // List action 111 | const listAction = hugo.action('list', (query) => { 112 | // Add items 113 | hugo.items.push({ 114 | title: 'Foo', 115 | subtitle: 'Bar', 116 | arg: 'foobar' 117 | }, { 118 | title: 'Hello', 119 | subtitle: 'World', 120 | arg: 'helloworld' 121 | }); 122 | 123 | // Flush output buffer 124 | hugo.feedback(); 125 | }); 126 | 127 | // Run matching actions 128 | hugo.run(); 129 | ``` 130 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [info@cloudstek.nl][abusemail]. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | [abusemail]: mailto:info@cloudstek.nl 76 | -------------------------------------------------------------------------------- /test/matching.ts: -------------------------------------------------------------------------------- 1 | import Test, { TestInterface } from 'ava'; 2 | 3 | import { hugo } from './helpers/init'; 4 | import { TestContext } from './helpers/types'; 5 | 6 | const test = Test as TestInterface; 7 | 8 | test.beforeEach((t) => { 9 | t.context.items = [ 10 | { 11 | title: 'Foo', 12 | subtitle: 'foo bar', 13 | }, 14 | { 15 | title: 'Bar', 16 | subtitle: 'foo bar bleep', 17 | }, 18 | { 19 | title: 'Eep', 20 | match: 'Abra', 21 | subtitle: 'eep foo blep', 22 | }, 23 | { 24 | title: 'Foo bar', 25 | subtitle: 'ploop', 26 | }, 27 | { 28 | title: 'Abra', 29 | subtitle: 'cadabra', 30 | }, 31 | ]; 32 | }); 33 | 34 | test('exact match', (t) => { 35 | const h = hugo(); 36 | 37 | const matches = h.match(t.context.items, 'Abra', { 38 | threshold: 0, 39 | shouldSort: false, 40 | }); 41 | 42 | t.true(Array.isArray(matches)); 43 | 44 | // Should match both item with match property as well as title property 45 | // See https://www.alfredapp.com/help/workflows/inputs/script-filter/json/#match 46 | t.is(matches.length, 2); 47 | t.deepEqual(matches[0], t.context.items[2]); 48 | t.deepEqual(matches[1], t.context.items[4]); 49 | }); 50 | 51 | test('exact match by single key', (t) => { 52 | const h = hugo(); 53 | 54 | const matches = h.match(t.context.items, 'foo bar bleep', { 55 | keys: ['subtitle'], 56 | threshold: 0, 57 | }); 58 | 59 | t.true(Array.isArray(matches)); 60 | t.is(matches.length, 1); 61 | t.deepEqual(matches[0], t.context.items[1]); 62 | }); 63 | 64 | test('exact match by single key using weighted syntax', (t) => { 65 | const h = hugo(); 66 | 67 | const matches = h.match(t.context.items, 'foo bar bleep', { 68 | keys: [ 69 | { name: 'subtitle', weight: 0 } 70 | ], 71 | threshold: 0, 72 | }); 73 | 74 | t.true(Array.isArray(matches)); 75 | t.is(matches.length, 1); 76 | t.deepEqual(matches[0], t.context.items[1]); 77 | }); 78 | 79 | test('exact match multiple keys', (t) => { 80 | const h = hugo(); 81 | 82 | const matches = h.match(t.context.items, 'foo', { 83 | keys: ['title', 'subtitle'], 84 | }); 85 | 86 | t.true(Array.isArray(matches)); 87 | t.is(matches.length, 4); 88 | t.snapshot(matches); 89 | }); 90 | 91 | test('exact match multiple keys using weighted syntax', (t) => { 92 | const h = hugo(); 93 | 94 | const matches = h.match(t.context.items, 'foo', { 95 | keys: [ 96 | { name: 'title', weight: 0 }, 97 | { name: 'subtitle', weight: 1 } 98 | ], 99 | }); 100 | 101 | t.true(Array.isArray(matches)); 102 | t.is(matches.length, 4); 103 | t.snapshot(matches); 104 | }); 105 | 106 | test('no matches', (t) => { 107 | const h = hugo(); 108 | 109 | const matches = h.match(t.context.items, 'nope', { 110 | threshold: 0, 111 | }); 112 | 113 | t.true(Array.isArray(matches)); 114 | t.is(matches.length, 0); 115 | }); 116 | 117 | test('no query should return all items', (t) => { 118 | const h = hugo(); 119 | 120 | const matches = h.match(t.context.items, ''); 121 | 122 | t.true(Array.isArray(matches)); 123 | t.is(matches.length, 5); 124 | t.snapshot(matches); 125 | }); 126 | -------------------------------------------------------------------------------- /test/helpers/mock.ts: -------------------------------------------------------------------------------- 1 | import readPkg from 'read-pkg-up'; 2 | import semver from 'semver'; 3 | import nock from 'nock'; 4 | import path from 'path'; 5 | import fs from 'fs-extra'; 6 | import crypto from 'crypto'; 7 | import mockdate from 'mockdate'; 8 | import moment, { DurationInputArg1, DurationInputArg2 } from 'moment'; 9 | 10 | export function date() { 11 | mockdate.set(moment.utc('2019-01-01T14:00:00').valueOf()); 12 | } 13 | 14 | export function forwardTime(amount?: DurationInputArg1, unit?: DurationInputArg2) { 15 | mockdate.set(moment.utc().add(amount, unit).toDate()); 16 | } 17 | 18 | export function npm(times: number, pkg?: any, code = 200, latestVersion?: string) { 19 | pkg = pkg || readPkg.sync().packageJson; 20 | 21 | if (latestVersion !== null) { 22 | latestVersion = latestVersion || semver.parse(pkg.version).inc('major').toString(); 23 | } 24 | 25 | // Build versions response 26 | const versions: { [key: string]: any } = { 27 | '1.0.0': { 28 | name: pkg.name, 29 | version: '1.0.0', 30 | }, 31 | }; 32 | 33 | versions[pkg.version] = { 34 | name: pkg.name, 35 | version: pkg.version, 36 | }; 37 | 38 | versions[latestVersion] = { 39 | name: pkg.name, 40 | version: latestVersion, 41 | }; 42 | 43 | const distTags: { [key: string]: any } = {}; 44 | 45 | if (latestVersion !== null) { 46 | distTags.latest = latestVersion; 47 | } 48 | 49 | // Response body 50 | let body = ''; 51 | 52 | if (code >= 200 && code <= 299) { 53 | body = JSON.stringify({ 54 | 'name': pkg.name, 55 | 'dist-tags': distTags, 56 | versions, 57 | }); 58 | } 59 | 60 | // Mock requests 61 | const url = 'https://registry.npmjs.org'; 62 | const urlPath = '/' + pkg.name; 63 | 64 | if (times >= 0) { 65 | for (let i = 0; i < times; i++) { 66 | nock(url) 67 | .get(urlPath) 68 | .reply(code, body) 69 | ; 70 | } 71 | 72 | return; 73 | } 74 | 75 | nock(url) 76 | .persist() 77 | .get(urlPath) 78 | .reply(code, body) 79 | ; 80 | } 81 | 82 | export function packal(times: number, code = 200, filename = 'appcast.xml') { 83 | filename = path.join('test', 'helpers', 'mocks', filename); 84 | 85 | // Response body 86 | let body = ''; 87 | 88 | if (code >= 200 && code <= 299) { 89 | body = fs.readFileSync(filename, { encoding: 'utf8' }); 90 | } 91 | 92 | // Mock requests 93 | const url = 'https://github.com'; 94 | const urlPath = '/packal/repository/blob/master/my.work.flow/appcast.xml'; 95 | 96 | if (times >= 0) { 97 | for (let i = 0; i < times; i++) { 98 | nock(url) 99 | .get(urlPath) 100 | .reply(code, body) 101 | ; 102 | } 103 | 104 | return; 105 | } 106 | 107 | nock(url) 108 | .persist() 109 | .get(urlPath) 110 | .reply(code, body) 111 | ; 112 | } 113 | 114 | export function file() { 115 | const filePath = path.join('build', 'cache', crypto.randomBytes(8).toString('hex')); 116 | 117 | fs.ensureFileSync(filePath); 118 | 119 | return filePath; 120 | } 121 | 122 | export function cleanDate() { 123 | mockdate.reset(); 124 | } 125 | 126 | export function cleanNock() { 127 | nock.cleanAll(); 128 | } 129 | 130 | export function cleanAll() { 131 | cleanDate(); 132 | cleanNock(); 133 | } 134 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "project": "tsconfig.json", 13 | "sourceType": "module" 14 | }, 15 | "plugins": [ 16 | "@typescript-eslint", 17 | "prefer-arrow" 18 | ], 19 | "rules": { 20 | "@typescript-eslint/adjacent-overload-signatures": "error", 21 | "@typescript-eslint/array-type": "error", 22 | "@typescript-eslint/ban-types": "error", 23 | "@typescript-eslint/class-name-casing": "error", 24 | "@typescript-eslint/consistent-type-assertions": "error", 25 | "@typescript-eslint/interface-name-prefix": "off", 26 | "@typescript-eslint/no-empty-function": "error", 27 | "@typescript-eslint/no-empty-interface": "error", 28 | "@typescript-eslint/no-explicit-any": "off", 29 | "@typescript-eslint/no-misused-new": "error", 30 | "@typescript-eslint/no-namespace": "error", 31 | "@typescript-eslint/no-parameter-properties": "off", 32 | "@typescript-eslint/no-use-before-define": "off", 33 | "@typescript-eslint/no-var-requires": "error", 34 | "@typescript-eslint/prefer-for-of": "error", 35 | "@typescript-eslint/prefer-function-type": "error", 36 | "@typescript-eslint/prefer-namespace-keyword": "error", 37 | "@typescript-eslint/triple-slash-reference": "error", 38 | "@typescript-eslint/unified-signatures": "error", 39 | "camelcase": "off", 40 | "complexity": "off", 41 | "constructor-super": "off", 42 | "dot-notation": "error", 43 | "eqeqeq": [ 44 | "error", 45 | "smart" 46 | ], 47 | "guard-for-in": "error", 48 | "id-blacklist": "off", 49 | "id-match": "off", 50 | "import/order": "off", 51 | "max-classes-per-file": [ 52 | "error", 53 | 1 54 | ], 55 | "max-len": [ 56 | "warn", 57 | { 58 | "code": 120 59 | } 60 | ], 61 | "new-parens": "error", 62 | "no-bitwise": "error", 63 | "no-caller": "error", 64 | "no-cond-assign": "error", 65 | "no-console": "off", 66 | "no-debugger": "error", 67 | "no-empty": "error", 68 | "no-eval": "error", 69 | "no-fallthrough": "off", 70 | "no-invalid-this": "off", 71 | "no-new-wrappers": "error", 72 | "no-shadow": [ 73 | "error", 74 | { 75 | "hoist": "all" 76 | } 77 | ], 78 | "no-throw-literal": "error", 79 | "no-trailing-spaces": "error", 80 | "no-undef-init": "error", 81 | "no-underscore-dangle": "off", 82 | "no-unsafe-finally": "error", 83 | "no-unused-expressions": "error", 84 | "no-unused-labels": "error", 85 | "no-var": "error", 86 | "object-shorthand": "error", 87 | "one-var": [ 88 | "error", 89 | "never" 90 | ], 91 | "prefer-arrow/prefer-arrow-functions": "error", 92 | "prefer-const": "error", 93 | "radix": "error", 94 | "spaced-comment": "error", 95 | "use-isnan": "error", 96 | "valid-typeof": "off", 97 | "quotes": ["error", "single", { "avoidEscape": true }] 98 | } 99 | }; 100 | -------------------------------------------------------------------------------- /test/meta/alfred.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | import test from 'ava'; 3 | import fs from 'fs-extra'; 4 | import path from 'path'; 5 | import sinon from 'sinon'; 6 | 7 | import { hugo } from '../helpers/init'; 8 | 9 | const backupConsoleError = console.error; 10 | 11 | test.serial('valid version', (t) => { 12 | const h = hugo(); 13 | 14 | process.env.alfred_version = '3.0.0'; 15 | 16 | // Check version number 17 | t.is(typeof h.alfredMeta, 'object'); 18 | t.is(h.alfredMeta.version, '3.0.0'); 19 | }); 20 | 21 | test.serial('single digit version', (t) => { 22 | const h = hugo(); 23 | 24 | process.env.alfred_version = '3'; 25 | 26 | // Check version number 27 | t.is(typeof h.alfredMeta, 'object'); 28 | t.is(h.alfredMeta.version, '3.0.0'); 29 | }); 30 | 31 | test.serial('two digit version', (t) => { 32 | const h = hugo(); 33 | 34 | process.env.alfred_version = '3.0'; 35 | 36 | // Check version number 37 | t.is(typeof h.alfredMeta, 'object'); 38 | t.is(h.alfredMeta.version, '3.0.0'); 39 | }); 40 | 41 | test.serial('no version', (t) => { 42 | const consoleStub = sinon.stub(console, 'error'); 43 | const h = hugo(); 44 | 45 | process.env.alfred_debug = '0'; 46 | delete process.env.alfred_version; 47 | 48 | // Check version number 49 | t.is(typeof h.alfredMeta, 'object'); 50 | t.falsy(h.alfredMeta.version); 51 | t.false(consoleStub.called); 52 | 53 | // Check if debug message is output 54 | process.env.alfred_debug = '1'; 55 | 56 | t.is(typeof h.alfredMeta, 'object'); 57 | t.falsy(h.alfredMeta.version); 58 | t.true(consoleStub.calledWith('Invalid Alfred version: undefined')); 59 | }); 60 | 61 | test.serial('invalid version', (t) => { 62 | const consoleStub = sinon.stub(console, 'error'); 63 | const h = hugo(); 64 | 65 | process.env.alfred_debug = '0'; 66 | process.env.alfred_version = 'foobar'; 67 | 68 | // Check version number 69 | t.is(typeof h.alfredMeta, 'object'); 70 | t.falsy(h.alfredMeta.version); 71 | t.false(consoleStub.called); 72 | 73 | // Check if debug message is output 74 | process.env.alfred_debug = '1'; 75 | 76 | t.is(typeof h.alfredMeta, 'object'); 77 | t.falsy(h.alfredMeta.version); 78 | t.true(consoleStub.calledWith('Invalid Alfred version: foobar')); 79 | }); 80 | 81 | test.serial('existing theme', (t) => { 82 | const h = hugo(); 83 | 84 | process.env.alfred_theme = 'foo'; 85 | 86 | const themeFilePath = path.resolve(process.env.alfred_preferences, 'themes', process.env.alfred_theme, 'theme.json'); 87 | 88 | fs.ensureFileSync(themeFilePath); 89 | fs.writeJsonSync(themeFilePath, { 90 | alfredtheme: { 91 | foo: 'bar', 92 | }, 93 | }); 94 | 95 | t.is(typeof h.alfredMeta, 'object'); 96 | t.is(h.alfredMeta.themeFile, themeFilePath); 97 | }); 98 | 99 | test.serial('non-existing theme', (t) => { 100 | const consoleStub = sinon.stub(console, 'error'); 101 | const h = hugo(); 102 | 103 | // Valid theme name but directory doesn't exist. 104 | process.env.alfred_debug = '0'; 105 | process.env.alfred_theme = 'default'; 106 | 107 | t.is(typeof h.alfredMeta, 'object'); 108 | t.falsy(h.alfredMeta.themeFile); 109 | t.false(consoleStub.called); 110 | 111 | // Check if debug message is output 112 | process.env.alfred_debug = '1'; 113 | 114 | t.is(typeof h.alfredMeta, 'object'); 115 | t.falsy(h.alfredMeta.themeFile); 116 | t.true(consoleStub.called); 117 | t.regex(consoleStub.getCall(0).args[0], /^Could not find theme file /); 118 | }); 119 | 120 | test.afterEach.always(() => { 121 | console.error = backupConsoleError; 122 | }); 123 | -------------------------------------------------------------------------------- /test/updates/packal.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import nock from 'nock'; 3 | 4 | import { UpdateSource } from '../../src'; 5 | 6 | import { updater, hugo } from '../helpers/init'; 7 | import * as mock from '../helpers/mock'; 8 | 9 | test.beforeEach(() => { 10 | mock.date(); 11 | }); 12 | 13 | test.serial('check with valid update source Packal', async (t) => { 14 | // Mock requests 15 | mock.packal(5); 16 | 17 | await t.notThrowsAsync(async () => { 18 | const h = hugo({ 19 | updateSource: 'packal', 20 | updateNotification: false, 21 | }); 22 | 23 | return h.checkUpdates(); 24 | }); 25 | 26 | await t.notThrowsAsync(async () => { 27 | const h = hugo({ 28 | updateSource: 'Packal', 29 | updateNotification: false, 30 | }); 31 | 32 | return h.checkUpdates(); 33 | }); 34 | 35 | await t.notThrowsAsync(async () => { 36 | const h = hugo({ 37 | updateSource: UpdateSource.Packal, 38 | updateNotification: false, 39 | }); 40 | 41 | return h.checkUpdates(); 42 | }); 43 | 44 | await t.notThrowsAsync(async () => { 45 | return updater().checkUpdates('packal'); 46 | }); 47 | 48 | await t.notThrowsAsync(async () => { 49 | return updater().checkUpdates('Packal'); 50 | }); 51 | }); 52 | 53 | test.serial('check for updates uncached', async (t) => { 54 | const u = updater(); 55 | 56 | // Mock request 57 | mock.packal(1); 58 | 59 | const update = await u.checkUpdates('packal'); 60 | 61 | if (!update) { 62 | t.fail('Update is undefined or false.'); 63 | return; 64 | } 65 | 66 | t.is(update.version, '2.0.0'); 67 | t.regex(update.url, /^https:\/\/encrypted\.google\.com\//); 68 | t.regex(update.url, /my\.work\.flow/); 69 | t.true(update.checkedOnline); 70 | }); 71 | 72 | test.serial('check for updates cached', async (t) => { 73 | const u = updater(); 74 | 75 | mock.packal(2); 76 | 77 | let update = await u.checkUpdates('packal'); 78 | 79 | if (!update) { 80 | t.fail('Update is undefined or false.'); 81 | return; 82 | } 83 | 84 | t.is(update.version, '2.0.0'); 85 | t.regex(update.url, /^https:\/\/encrypted\.google\.com\//); 86 | t.regex(update.url, /my\.work\.flow/); 87 | t.true(update.checkedOnline); 88 | t.is(nock.pendingMocks().length, 1); 89 | 90 | // Forward time 91 | mock.forwardTime(30, 'minutes'); 92 | 93 | // Check for updates again, should be cached. 94 | update = await u.checkUpdates('packal'); 95 | 96 | if (!update) { 97 | t.fail('Update is undefined or false.'); 98 | return; 99 | } 100 | 101 | t.false(update.checkedOnline); 102 | t.is(nock.pendingMocks().length, 1); 103 | 104 | // Forward time 105 | mock.forwardTime(30, 'minutes'); 106 | 107 | // Check for updates again, should be checked online 108 | update = await u.checkUpdates('packal'); 109 | 110 | if (!update) { 111 | t.fail('Update is undefined or false.'); 112 | return; 113 | } 114 | 115 | t.true(update.checkedOnline); 116 | }); 117 | 118 | test.serial('check for updates with no bundle id set', async (t) => { 119 | const u = updater(); 120 | 121 | await t.throwsAsync(async () => { 122 | process.env.alfred_workflow_bundleid = undefined; 123 | delete process.env.alfred_workflow_bundleid; 124 | 125 | return u.checkUpdates('packal'); 126 | }, {instanceOf: Error, message: 'No bundle ID, not checking Packal for updates.'}); 127 | }); 128 | 129 | test.serial('check for updates when no version is returned', async (t) => { 130 | const u = updater(); 131 | 132 | mock.packal(1, 200, 'appcast-noversion.xml'); 133 | 134 | await t.throwsAsync(async () => { 135 | return u.checkUpdates('packal'); 136 | }, {instanceOf: Error, message: 'No version found.'}); 137 | }); 138 | 139 | test.serial('check for updates when invalid version is returned', async (t) => { 140 | const u = updater(); 141 | 142 | mock.packal(1, 200, 'appcast-invalidversion.xml'); 143 | 144 | await t.throwsAsync(async () => { 145 | return u.checkUpdates('packal'); 146 | }, {instanceOf: Error, message: 'Invalid version in response.'}); 147 | }); 148 | 149 | test.serial('check for updates with unpublished workflow', async (t) => { 150 | const u = updater(); 151 | 152 | // Mock request 153 | mock.packal(1, 404); 154 | 155 | const update = await u.checkUpdates('packal'); 156 | 157 | t.is(typeof update, 'undefined'); 158 | }); 159 | 160 | test.afterEach((t) => { 161 | if (!nock.isDone()) { 162 | t.fail('Not all requests were performed.'); 163 | } 164 | }); 165 | 166 | test.afterEach.always(() => { 167 | mock.cleanAll(); 168 | }); 169 | -------------------------------------------------------------------------------- /src/updater.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from '@cloudstek/cache'; 2 | import moment from 'moment'; 3 | import readPkg from 'read-pkg-up'; 4 | import axios from 'axios'; 5 | import semver from 'semver'; 6 | 7 | import { LatestVersion, UpdateSource } from './types'; 8 | 9 | /** 10 | * Hugo updater 11 | */ 12 | export class Updater { 13 | private readonly cache: Cache; 14 | private readonly interval: number | moment.Duration; 15 | 16 | /** 17 | * Hugo updater 18 | */ 19 | constructor(cache: Cache, interval: number | moment.Duration) { 20 | this.cache = cache; 21 | this.interval = interval; 22 | } 23 | 24 | /** 25 | * Check for updates 26 | * 27 | * @param source Update source (npm or packal) 28 | * @param pkg Package.json contents. When undefined, will read from file. 29 | */ 30 | public async checkUpdates(source: string, pkg?: any): Promise { 31 | // Check update source 32 | if (!UpdateSource[source as any]) { 33 | throw new Error('Invalid update source.'); 34 | } 35 | 36 | const latest = this.cache.get(`latest_version_${source}`) as LatestVersion | undefined; 37 | const lastCheck = moment.unix(this.cache.get(`last_check_${source}`) as number).utc(); 38 | 39 | // Check for updates online 40 | if (!latest) { 41 | // Check if the interval is past 42 | if (lastCheck.isValid() && lastCheck.add(this.interval).isSameOrAfter(moment.utc())) { 43 | return undefined; 44 | } 45 | 46 | switch (source.toLowerCase()) { 47 | case 'npm': 48 | return this.checkNpm(pkg); 49 | case 'packal': 50 | return this.checkPackal(); 51 | } 52 | } 53 | 54 | // Got it from cache! 55 | latest.checkedOnline = false; 56 | 57 | return latest; 58 | } 59 | 60 | /** 61 | * Check Packal for updates 62 | */ 63 | private async checkPackal(): Promise { 64 | // Bundle ID 65 | const bundleId = process.env.alfred_workflow_bundleid; 66 | 67 | if (!bundleId) { 68 | throw new Error('No bundle ID, not checking Packal for updates.'); 69 | } 70 | 71 | // Set last check time 72 | this.cache.set('last_check_packal', moment.utc().unix(), this.interval); 73 | 74 | // Packal URL 75 | const searchParam: string = encodeURIComponent('site:packal.org ' + bundleId); 76 | const pkgUrl = `https://encrypted.google.com/search?sourceid=chrome&ie=UTF-8&q=${searchParam}&btnI`; 77 | 78 | const latest = await axios.get(`https://github.com/packal/repository/blob/master/${bundleId}/appcast.xml`) 79 | .then((response) => { 80 | // Get version from XML 81 | const versionMatches = response.data.match(/(.+)<\/version>/); 82 | 83 | if (!versionMatches || versionMatches.length !== 2) { 84 | throw new Error('No version found.'); 85 | } 86 | 87 | if (!semver.valid(semver.coerce(versionMatches[1]))) { 88 | throw new Error('Invalid version in response.'); 89 | } 90 | 91 | return { 92 | version: versionMatches[1], 93 | url: pkgUrl, 94 | checkedOnline: true, 95 | }; 96 | }) 97 | .catch((err) => { 98 | if (err.response && err.response.status === 404) { 99 | return; 100 | } 101 | 102 | throw err; 103 | }); 104 | 105 | // Cache results 106 | this.cache.set('latest_version_packal', latest, this.interval); 107 | 108 | return latest; 109 | } 110 | 111 | /** 112 | * Check NPM for updates 113 | * 114 | * @param pkg Package.json contents. When undefined, will read from file. 115 | */ 116 | private async checkNpm(pkg?: any): Promise { 117 | // Get details from package.json 118 | pkg = pkg || readPkg.sync().packageJson; 119 | 120 | if (!pkg.name || !pkg.version) { 121 | throw new Error('Invalid package.json.'); 122 | } 123 | 124 | // Set last check time 125 | this.cache.set('last_check_npm', moment.utc().unix(), this.interval); 126 | 127 | // NPM URL 128 | const pkgUrl = `https://www.npmjs.com/package/${pkg.name}`; 129 | 130 | // Check for updates 131 | const latest = await axios.get(`https://registry.npmjs.org/${pkg.name}`) 132 | .then((response) => { 133 | if (!response.data['dist-tags'].latest) { 134 | throw new Error('No latest version found in response.'); 135 | } 136 | 137 | if (!semver.valid(semver.coerce(response.data['dist-tags'].latest))) { 138 | throw new Error('Invalid version in response.'); 139 | } 140 | 141 | return { 142 | version: response.data['dist-tags'].latest, 143 | url: pkgUrl, 144 | checkedOnline: true, 145 | }; 146 | }) 147 | .catch((err) => { 148 | if (err.response && err.response.status === 404) { 149 | return; 150 | } 151 | 152 | throw err; 153 | }); 154 | 155 | // Cache results 156 | this.cache.set('latest_version_npm', latest, this.interval); 157 | 158 | return latest; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /test/hugo.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import moment from 'moment'; 3 | import fs from 'fs-extra'; 4 | import path from 'path'; 5 | import os from 'os'; 6 | 7 | import utils from '../src/utils'; 8 | 9 | import { hugo } from './helpers/init'; 10 | 11 | import { UpdateSource, Hugo } from '../src'; 12 | 13 | test('initialize with options through constructor', (t) => { 14 | const hugoDefaults = (new Hugo() as any).options; 15 | 16 | const h = hugo({ 17 | checkUpdates: false, 18 | updateInterval: moment.duration(2, 'hours'), 19 | updateItem: false, 20 | updateNotification: false, 21 | updateSource: UpdateSource.Packal, 22 | }); 23 | 24 | t.notDeepEqual((h as any).options, hugoDefaults); 25 | t.deepEqual((h as any).options, { 26 | checkUpdates: false, 27 | updateInterval: moment.duration(2, 'hours'), 28 | updateItem: false, 29 | updateNotification: false, 30 | updateSource: UpdateSource.Packal, 31 | }); 32 | }); 33 | 34 | test('initialize with update interval as number', (t) => { 35 | const hugoDefaults = (new Hugo() as any).options; 36 | 37 | const h = hugo({ 38 | updateInterval: 4010, 39 | }); 40 | 41 | t.deepEqual((h as any).options, Object.assign({}, hugoDefaults, { 42 | updateInterval: moment.duration(4010, 'seconds'), 43 | })); 44 | }); 45 | 46 | test('initialize with invalid update interval as number', (t) => { 47 | const h = hugo({ 48 | updateInterval: -100, 49 | }); 50 | 51 | t.falsy((h as any).options.updateInterval); 52 | t.false((h as any).options.checkUpdates); 53 | }); 54 | 55 | test('initialize with update interval as duration', (t) => { 56 | const hugoDefaults = (new Hugo() as any).options; 57 | 58 | const h = hugo({ 59 | updateInterval: moment.duration(3, 'hours'), 60 | }); 61 | 62 | t.deepEqual((h as any).options, Object.assign({}, hugoDefaults, { 63 | updateInterval: moment.duration(3, 'hours'), 64 | })); 65 | }); 66 | 67 | test('initialize with invalid update interval as duration', (t) => { 68 | const h = hugo({ 69 | updateInterval: moment.duration(-10, 'seconds'), 70 | }); 71 | 72 | t.falsy((h as any).options.updateInterval); 73 | t.false((h as any).options.checkUpdates); 74 | }); 75 | 76 | test('test reset', (t) => { 77 | const h = hugo(); 78 | 79 | // Add item 80 | h.items.push({ 81 | title: 'foo', 82 | }); 83 | 84 | // Add variable 85 | h.variables.foo = 'bar'; 86 | 87 | // Set rerun 88 | h.rerun = 3.1; 89 | 90 | t.is(h.items.length, 1); 91 | t.is(h.variables.foo, 'bar'); 92 | t.is(h.rerun, 3.1); 93 | 94 | h.reset(); 95 | 96 | t.is(h.items.length, 0); 97 | t.deepEqual(h.variables, {}); 98 | t.falsy(h.rerun); 99 | }); 100 | 101 | test('input with no actions', (t) => { 102 | process.argv = [ 103 | 'node', 104 | 'index.js', 105 | 'foo', 106 | 'bar', 107 | 'hello', 108 | 'world', 109 | ]; 110 | 111 | const h = hugo(); 112 | 113 | t.is(h.input.length, 4); 114 | t.is(h.input[0], 'foo'); 115 | t.is(h.input[1], 'bar'); 116 | t.is(h.input[2], 'hello'); 117 | t.is(h.input[3], 'world'); 118 | }); 119 | 120 | test('input with no input', (t) => { 121 | process.argv = [ 122 | 'node', 123 | 'index.js', 124 | ]; 125 | 126 | const h = hugo(); 127 | 128 | t.is(h.input.length, 0); 129 | }); 130 | 131 | test('notify', async (t) => { 132 | if (os.platform() !== 'darwin') { 133 | t.pass('Notifications only work on MacOS.'); 134 | return; 135 | } 136 | 137 | const h = hugo(); 138 | 139 | await t.notThrowsAsync(async () => { 140 | return h.notify({ 141 | title: 'Foo', 142 | message: 'Bar', 143 | timeout: 1, 144 | }); 145 | }); 146 | }); 147 | 148 | test('notify with missing options', async (t) => { 149 | if (os.platform() !== 'darwin') { 150 | t.pass('Notifications only work on MacOS.'); 151 | return; 152 | } 153 | 154 | const h = hugo(); 155 | 156 | await t.throwsAsync(async () => { 157 | return h.notify({ 158 | title: 'Foo', 159 | }); 160 | }); 161 | }); 162 | 163 | test('clear cache dir', async (t) => { 164 | const h = hugo(); 165 | 166 | fs.writeJsonSync(path.join(h.workflowMeta.cache, 'foo.json'), { 167 | foo: 'bar', 168 | }); 169 | 170 | fs.writeFileSync(path.join(h.workflowMeta.cache, 'bar'), 'foo'); 171 | 172 | t.true(utils.fileExists(path.join(h.workflowMeta.cache, 'foo.json'))); 173 | t.true(utils.fileExists(path.join(h.workflowMeta.cache, 'bar'))); 174 | 175 | await h.clearCache(); 176 | 177 | t.false(utils.fileExists(path.join(h.workflowMeta.cache, 'foo.json'))); 178 | t.false(utils.fileExists(path.join(h.workflowMeta.cache, 'bar'))); 179 | t.true(utils.fileExists(h.workflowMeta.cache)); 180 | }); 181 | 182 | test('clear cache dir sync', (t) => { 183 | const h = hugo(); 184 | 185 | fs.writeJsonSync(path.join(h.workflowMeta.cache, 'foo.json'), { 186 | foo: 'bar', 187 | }); 188 | 189 | fs.writeFileSync(path.join(h.workflowMeta.cache, 'bar'), 'foo'); 190 | 191 | t.true(utils.fileExists(path.join(h.workflowMeta.cache, 'foo.json'))); 192 | t.true(utils.fileExists(path.join(h.workflowMeta.cache, 'bar'))); 193 | 194 | h.clearCacheSync(); 195 | 196 | t.false(utils.fileExists(path.join(h.workflowMeta.cache, 'foo.json'))); 197 | t.false(utils.fileExists(path.join(h.workflowMeta.cache, 'bar'))); 198 | t.true(utils.fileExists(h.workflowMeta.cache)); 199 | }); 200 | 201 | test.serial('clear cache dir without cache dir set', async (t) => { 202 | const h = hugo(); 203 | 204 | delete process.env.alfred_workflow_cache; 205 | 206 | t.falsy(h.workflowMeta.cache); 207 | 208 | await t.notThrowsAsync(async () => { 209 | return h.clearCache(); 210 | }); 211 | }); 212 | 213 | test.serial('clear cache dir sync without cache dir set', async (t) => { 214 | const h = hugo(); 215 | 216 | delete process.env.alfred_workflow_cache; 217 | 218 | t.falsy(h.workflowMeta.cache); 219 | 220 | t.notThrows(() => { 221 | h.clearCacheSync(); 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /media/logo-hugo.svg: -------------------------------------------------------------------------------- 1 | hugo-versie-1Hugo. -------------------------------------------------------------------------------- /test/updates/updater.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import moment from 'moment'; 4 | import nock from 'nock'; 5 | 6 | import { hugo, updater } from '../helpers/init'; 7 | import * as mock from '../helpers/mock'; 8 | 9 | const backupConsoleError = console.error; 10 | 11 | test.beforeEach(() => { 12 | mock.date(); 13 | }); 14 | 15 | test('check with invalid update source as string', async (t) => { 16 | t.throws(() => { 17 | hugo({ 18 | updateSource: 'foobar', 19 | }); 20 | }, {instanceOf: Error, message: 'Invalid update source.'}); 21 | 22 | await t.throwsAsync(async () => { 23 | return updater().checkUpdates('foobar'); 24 | }, {instanceOf: Error, message: 'Invalid update source.'}); 25 | }); 26 | 27 | test.serial('check update with no new updates', async (t) => { 28 | const h = hugo({ 29 | updateSource: 'packal', 30 | }); 31 | 32 | // Ensure that no new version exists 33 | process.env.alfred_workflow_version = '3.0.0'; 34 | 35 | // Mock request 36 | mock.packal(1); 37 | 38 | // Check for updates 39 | await h.checkUpdates(); 40 | t.true(nock.isDone()); 41 | 42 | // Check output buffer 43 | t.is(h.output.items.length, 0); 44 | }); 45 | 46 | test.serial('update notification only when checked online', async (t) => { 47 | const h = hugo({ 48 | updateSource: 'packal', 49 | updateItem: false, 50 | }); 51 | 52 | const notifyStub = sinon.stub(h, 'notify'); 53 | 54 | // Mock request 55 | mock.packal(1); 56 | 57 | // Check for updates 58 | await h.checkUpdates(); 59 | t.true(nock.isDone()); 60 | 61 | // Check for updates again (should be cached) 62 | await h.checkUpdates(); 63 | 64 | // Notify should only be called once 65 | t.true(notifyStub.calledOnce); 66 | 67 | // Make sure update item was not added 68 | t.is(h.items.length, 0); 69 | }); 70 | 71 | test.serial('update item only', async (t) => { 72 | const h = hugo({ 73 | updateSource: 'packal', 74 | updateNotification: false, 75 | }); 76 | 77 | const notifyStub = sinon.stub(h, 'notify'); 78 | 79 | // Mock request 80 | mock.packal(1); 81 | 82 | // Check for updates 83 | await h.checkUpdates(); 84 | t.true(nock.isDone()); 85 | 86 | // Check for updates again (should be cached) 87 | await h.checkUpdates(); 88 | 89 | // Notify should only be called once 90 | t.false(notifyStub.calledOnce); 91 | 92 | // Make sure update item was added 93 | t.is(h.items.length, 1); 94 | 95 | // Check update item 96 | const item = h.output.items.pop(); 97 | 98 | t.snapshot(item); 99 | }); 100 | 101 | test.serial('check update item', async (t) => { 102 | const h = hugo({ 103 | updateSource: 'packal', 104 | updateNotification: false, 105 | }); 106 | 107 | // Mock request 108 | mock.packal(1); 109 | 110 | // Check for updates 111 | await h.checkUpdates(); 112 | t.true(nock.isDone()); 113 | 114 | // Check output buffer 115 | t.is(h.output.items.length, 1); 116 | 117 | // Check update item 118 | const item = h.output.items.pop(); 119 | 120 | t.snapshot(item); 121 | }); 122 | 123 | test.serial('check for unpublished workflow twice within interval', async (t) => { 124 | const u = updater(); 125 | 126 | // Mock request 127 | mock.packal(1, 404); 128 | 129 | // Check for update 130 | let update = await u.checkUpdates('packal'); 131 | 132 | t.is(typeof update, 'undefined'); 133 | t.true(nock.isDone()); 134 | 135 | // Check for update the second time shortly after. No request should be made. 136 | update = await u.checkUpdates('packal'); 137 | 138 | t.is(typeof update, 'undefined'); 139 | }); 140 | 141 | test.serial('check for updates with updates disabled', async (t) => { 142 | const h = hugo({ 143 | checkUpdates: false, 144 | updateSource: 'npm', 145 | }); 146 | 147 | // Mock request 148 | mock.npm(1); 149 | 150 | const update = await h.checkUpdates(); 151 | 152 | t.falsy(update); 153 | t.false(nock.isDone()); 154 | 155 | nock.cleanAll(); 156 | }); 157 | 158 | test.serial('check for updates with update notification and item disabled', async (t) => { 159 | const h = hugo({ 160 | updateNotification: false, 161 | updateItem: false, 162 | updateSource: 'npm', 163 | }); 164 | 165 | // Mock request 166 | mock.npm(1); 167 | 168 | const update = await h.checkUpdates(); 169 | 170 | t.falsy(update); 171 | t.false(nock.isDone()); 172 | 173 | nock.cleanAll(); 174 | }); 175 | 176 | test.serial('check for updates with updateInterval undefined', async (t) => { 177 | const h = hugo({ 178 | updateInterval: undefined, 179 | updateSource: 'npm', 180 | }); 181 | 182 | // Mock request 183 | mock.npm(1); 184 | 185 | const update = await h.checkUpdates(); 186 | 187 | t.falsy(update); 188 | t.false(nock.isDone()); 189 | 190 | nock.cleanAll(); 191 | }); 192 | 193 | test.serial('check for updates with updateInterval under one second', async (t) => { 194 | const h = hugo({ 195 | updateInterval: moment.duration(1, 'milliseconds'), 196 | updateSource: 'npm', 197 | }); 198 | 199 | // Mock request 200 | mock.npm(1); 201 | 202 | const update = await h.checkUpdates(); 203 | 204 | t.falsy(update); 205 | t.false(nock.isDone()); 206 | 207 | nock.cleanAll(); 208 | }); 209 | 210 | test.serial('check for updates with invalid workflow version', async (t) => { 211 | const h = hugo({ 212 | updateSource: 'packal', 213 | }); 214 | 215 | process.env.alfred_workflow_version = 'foobar'; 216 | 217 | // Mock request 218 | mock.packal(1); 219 | 220 | const update = await h.checkUpdates(); 221 | 222 | t.falsy(update); 223 | t.true(nock.isDone()); 224 | }); 225 | 226 | test.serial('check for updates with updates with unpublished package', async (t) => { 227 | const h = hugo({ 228 | updateSource: 'packal', 229 | }); 230 | 231 | // Mock request 232 | mock.packal(1, 404); 233 | 234 | const update = await h.checkUpdates(); 235 | 236 | t.falsy(update); 237 | t.true(nock.isDone()); 238 | }); 239 | 240 | test.serial('check for updates with updates when exception occurs', async (t) => { 241 | const consoleStub = sinon.stub(console, 'error'); 242 | 243 | const h = hugo({ 244 | updateSource: 'packal', 245 | }); 246 | 247 | // Mock request 248 | mock.packal(2, 500); 249 | 250 | let update = await h.checkUpdates(); 251 | 252 | t.falsy(update); 253 | t.is(nock.pendingMocks().length, 1); 254 | t.false(consoleStub.called); 255 | 256 | // Clear cache 257 | h.cache.clear(); 258 | 259 | // Check if debug message is output 260 | process.env.alfred_debug = '1'; 261 | 262 | update = await h.checkUpdates(); 263 | 264 | t.falsy(update); 265 | t.true(nock.isDone()); 266 | t.true(consoleStub.calledWith('Request failed with status code 500')); 267 | }); 268 | 269 | test.afterEach((t) => { 270 | if (!nock.isDone()) { 271 | t.fail('Not all requests were performed.'); 272 | } 273 | }); 274 | 275 | test.afterEach.always(() => { 276 | console.error = backupConsoleError; 277 | 278 | mock.cleanAll(); 279 | }); 280 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | import test from 'ava'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | import fs from 'fs-extra'; 6 | import crypto from 'crypto'; 7 | import sinon from 'sinon'; 8 | import bplist from 'bplist-creator'; 9 | import semver from 'semver'; 10 | 11 | // Stub home directory 12 | const homeDir = path.resolve('build', 'cache', crypto.randomBytes(8).toString('hex')); 13 | sinon.stub(os, 'homedir').returns(homeDir); 14 | 15 | import utils from '../src/utils'; 16 | import { hugo } from './helpers/init'; 17 | 18 | test.serial('resolve alfred 3 preferences', (t) => { 19 | // Write new binary plist 20 | const plist = bplist({}); 21 | 22 | // Write file to expected path 23 | const plistPath = path.join(homeDir, '/Library/Preferences/com.runningwithcrayons.Alfred-Preferences-3.plist'); 24 | 25 | fs.ensureFileSync(plistPath); 26 | fs.writeFileSync(plistPath, plist); 27 | 28 | // Resolve preferences file 29 | const p = utils.resolveAlfredPrefs('3.0.0'); 30 | const pSemver = utils.resolveAlfredPrefs(semver.parse('3.0.0')); 31 | 32 | t.is(p, path.join(homeDir, 'Library/Application Support/Alfred 3/Alfred.alfredpreferences')); 33 | t.is(pSemver, p); 34 | }); 35 | 36 | test.serial('resolve alfred 4 preferences', (t) => { 37 | // Write new binary plist 38 | const plist = bplist({}); 39 | 40 | // Write file to expected path 41 | const plistPath = path.join(homeDir, '/Library/Preferences/com.runningwithcrayons.Alfred-Preferences.plist'); 42 | 43 | fs.ensureFileSync(plistPath); 44 | fs.writeFileSync(plistPath, plist); 45 | 46 | // Resolve preferences file 47 | const p = utils.resolveAlfredPrefs('4.0.0'); 48 | const pSemver = utils.resolveAlfredPrefs(semver.parse('4.0.0')); 49 | 50 | t.is(p, path.join(homeDir, 'Library/Application Support/Alfred/Alfred.alfredpreferences')); 51 | t.is(pSemver, p); 52 | }); 53 | 54 | test.serial('resolve randomly versioned preferences', (t) => { 55 | // Write new binary plist 56 | const plist = bplist({}); 57 | 58 | const major = Math.floor(Math.random() * (15 - 5)) + 5; 59 | const version = `${major}.0.0`; 60 | 61 | t.log(`Using version: ${version}`); 62 | 63 | // Write file to expected path 64 | const plistPath = path.join(homeDir, `/Library/Preferences/com.runningwithcrayons.Alfred-Preferences-${major}.plist`); 65 | 66 | fs.ensureFileSync(plistPath); 67 | fs.writeFileSync(plistPath, plist); 68 | 69 | // Resolve preferences file 70 | const p = utils.resolveAlfredPrefs(version); 71 | const pSemver = utils.resolveAlfredPrefs(semver.parse(version)); 72 | 73 | t.is(p, path.join(homeDir, `Library/Application Support/Alfred ${major}/Alfred.alfredpreferences`)); 74 | t.is(pSemver, p); 75 | }); 76 | 77 | test.serial('prefer unversioned preferences over versioned preferences', (t) => { 78 | // Write new binary plist 79 | const plist = bplist({}); 80 | 81 | // Write file to expected path 82 | const plist3Path = path.join(homeDir, '/Library/Preferences/com.runningwithcrayons.Alfred-Preferences-3.plist'); 83 | const plist4Path = path.join(homeDir, '/Library/Preferences/com.runningwithcrayons.Alfred-Preferences.plist'); 84 | 85 | fs.ensureFileSync(plist3Path); 86 | fs.writeFileSync(plist3Path, plist); 87 | 88 | fs.ensureFileSync(plist4Path); 89 | fs.writeFileSync(plist4Path, plist); 90 | 91 | // Resolve preferences file 92 | const p = utils.resolveAlfredPrefs('4.0.0'); 93 | const pSemver = utils.resolveAlfredPrefs(semver.parse('4.0.0')); 94 | 95 | t.is(p, path.join(homeDir, 'Library/Application Support/Alfred/Alfred.alfredpreferences')); 96 | t.is(pSemver, p); 97 | }); 98 | 99 | test.serial('resolve synced alfred 3 preferences', (t) => { 100 | // Write new binary plist 101 | const plist = bplist({ 102 | syncfolder: '~/Dropbox', 103 | }); 104 | 105 | // Write file to expected path 106 | const plistPath = path.join(homeDir, '/Library/Preferences/com.runningwithcrayons.Alfred-Preferences-3.plist'); 107 | 108 | fs.ensureFileSync(plistPath); 109 | fs.writeFileSync(plistPath, plist); 110 | 111 | // Resolve preferences file 112 | const p = utils.resolveAlfredPrefs('3.0.0'); 113 | const pSemver = utils.resolveAlfredPrefs(semver.parse('3.0.0')); 114 | 115 | t.is(p, path.join(homeDir, 'Dropbox/Alfred.alfredpreferences')); 116 | t.is(pSemver, p); 117 | }); 118 | 119 | test('resolve synced alfred 4 preferences', (t) => { 120 | // Write new binary plist 121 | const plist = bplist({ 122 | syncfolder: '~/Dropbox', 123 | }); 124 | 125 | // Write file to expected path 126 | const plistPath = path.join(homeDir, '/Library/Preferences/com.runningwithcrayons.Alfred-Preferences.plist'); 127 | 128 | fs.ensureFileSync(plistPath); 129 | fs.writeFileSync(plistPath, plist); 130 | 131 | // Resolve preferences file 132 | const p = utils.resolveAlfredPrefs('4.0.0'); 133 | const pSemver = utils.resolveAlfredPrefs(semver.parse('4.0.0')); 134 | 135 | t.is(p, path.join(homeDir, 'Dropbox/Alfred.alfredpreferences')); 136 | t.is(pSemver, p); 137 | }); 138 | 139 | test.serial('resolve randomly versioned synced preferences', (t) => { 140 | // Write new binary plist 141 | const plist = bplist({ 142 | syncfolder: '~/Dropbox', 143 | }); 144 | 145 | const major = Math.floor(Math.random() * (15 - 5)) + 5; 146 | const version = `${major}.0.0`; 147 | 148 | t.log(`Using version: ${version}`); 149 | 150 | // Write file to expected path 151 | const plistPath = path.join(homeDir, `/Library/Preferences/com.runningwithcrayons.Alfred-Preferences-${major}.plist`); 152 | 153 | fs.ensureFileSync(plistPath); 154 | fs.writeFileSync(plistPath, plist); 155 | 156 | // Resolve preferences file 157 | const p = utils.resolveAlfredPrefs(version); 158 | const pSemver = utils.resolveAlfredPrefs(semver.parse(version)); 159 | 160 | t.is(p, path.join(homeDir, 'Dropbox/Alfred.alfredpreferences')); 161 | t.is(pSemver, p); 162 | }); 163 | 164 | test.serial('resolve non-existing alfred 3 preferences', (t) => { 165 | // Write new binary plist 166 | const plist = bplist({}); 167 | 168 | // Write alfred 4 preferences file 169 | const plistPath = path.join(homeDir, '/Library/Preferences/com.runningwithcrayons.Alfred-Preferences.plist'); 170 | 171 | fs.ensureFileSync(plistPath); 172 | fs.writeFileSync(plistPath, plist); 173 | 174 | // Resolve preferences file for alfred 3 175 | t.throws(() => { 176 | utils.resolveAlfredPrefs('3.0.0'); 177 | }); 178 | 179 | t.throws(() => { 180 | utils.resolveAlfredPrefs(semver.parse('3.0.0')); 181 | }); 182 | }); 183 | 184 | test.serial('resolve alfred 3 preferences using alfredMeta', (t) => { 185 | const plist = bplist({}); 186 | const h = hugo(); 187 | 188 | process.env.alfred_version = '3.0.0'; 189 | delete process.env.alfred_preferences; 190 | 191 | // Write file to expected path 192 | const plistPath = path.join(homeDir, '/Library/Preferences/com.runningwithcrayons.Alfred-Preferences-3.plist'); 193 | 194 | fs.ensureFileSync(plistPath); 195 | fs.writeFileSync(plistPath, plist); 196 | 197 | t.is(typeof h.alfredMeta, 'object'); 198 | t.is(h.alfredMeta.preferences, path.join(homeDir, 'Library/Application Support/Alfred 3/Alfred.alfredpreferences')); 199 | }); 200 | 201 | test.afterEach.always(() => { 202 | fs.removeSync(homeDir); 203 | }); 204 | -------------------------------------------------------------------------------- /test/updates/npm.ts: -------------------------------------------------------------------------------- 1 | import Test, { TestInterface } from 'ava'; 2 | import semver from 'semver'; 3 | import nock from 'nock'; 4 | import readPkg from 'read-pkg-up'; 5 | 6 | import { UpdateSource } from '../../src'; 7 | 8 | import { updater, hugo } from '../helpers/init'; 9 | import { TestContext } from '../helpers/types'; 10 | import * as mock from '../helpers/mock'; 11 | 12 | const test = Test as TestInterface; 13 | 14 | test.beforeEach(() => { 15 | mock.date(); 16 | }); 17 | 18 | test.serial('check with valid update source NPM', async (t) => { 19 | // Mock requests 20 | mock.npm(5); 21 | 22 | await t.notThrowsAsync(async () => { 23 | const h = hugo({ 24 | updateSource: 'npm', 25 | updateNotification: false, 26 | }); 27 | 28 | return h.checkUpdates(); 29 | }); 30 | 31 | await t.notThrowsAsync(async () => { 32 | const h = hugo({ 33 | updateSource: 'NPM', 34 | updateNotification: false, 35 | }); 36 | 37 | return h.checkUpdates(); 38 | }); 39 | 40 | await t.notThrowsAsync(async () => { 41 | const h = hugo({ 42 | updateSource: UpdateSource.NPM, 43 | updateNotification: false, 44 | }); 45 | 46 | return h.checkUpdates(); 47 | }); 48 | 49 | await t.notThrowsAsync(async () => { 50 | return updater().checkUpdates('npm'); 51 | }); 52 | 53 | await t.notThrowsAsync(async () => { 54 | return updater().checkUpdates('NPM'); 55 | }); 56 | }); 57 | 58 | test.serial('check for updates uncached', async (t) => { 59 | const u = updater(); 60 | 61 | // Mock request 62 | mock.npm(1); 63 | 64 | // Package 65 | const pkg = readPkg.sync().packageJson; 66 | 67 | const update = await u.checkUpdates('npm'); 68 | 69 | if (!update) { 70 | t.fail('Update is undefined or false.'); 71 | return; 72 | } 73 | 74 | t.is(update.version, semver.parse(pkg.version).inc('major').toString()); 75 | t.is(update.url, `https://www.npmjs.com/package/${pkg.name}`); 76 | t.true(update.checkedOnline); 77 | }); 78 | 79 | test.serial('check for updates uncached with custom package.json', async (t) => { 80 | const u = updater(); 81 | 82 | // Package 83 | const pkg = { 84 | name: 'alfred-my-workflow', 85 | version: '1.0.0', 86 | }; 87 | 88 | // Mock request 89 | mock.npm(1, pkg); 90 | 91 | const update = await u.checkUpdates('npm', pkg); 92 | 93 | if (!update) { 94 | t.fail('Update is undefined or false.'); 95 | return; 96 | } 97 | 98 | t.is(update.version, '2.0.0'); 99 | t.is(update.url, `https://www.npmjs.com/package/${pkg.name}`); 100 | t.true(update.checkedOnline); 101 | }); 102 | 103 | test.serial('check for updates cached', async (t) => { 104 | const u = updater(); 105 | 106 | // Mock requests 107 | mock.npm(2); 108 | 109 | // Package 110 | const pkg = readPkg.sync().packageJson; 111 | 112 | // Check for updates 113 | let update = await u.checkUpdates('npm'); 114 | 115 | if (!update) { 116 | t.fail('Update is undefined or false.'); 117 | return; 118 | } 119 | 120 | t.is(update.version, semver.parse(pkg.version).inc('major').toString()); 121 | t.is(update.url, `https://www.npmjs.com/package/${pkg.name}`); 122 | t.true(update.checkedOnline); 123 | 124 | // Forward time 125 | mock.forwardTime(30, 'minutes'); 126 | 127 | // Check for updates again, should be cached. 128 | update = await u.checkUpdates('npm'); 129 | 130 | if (!update) { 131 | t.fail('Update is undefined or false.'); 132 | return; 133 | } 134 | 135 | t.false(update.checkedOnline); 136 | 137 | // Forward time 138 | mock.forwardTime(30, 'minutes'); 139 | 140 | // Check for updates again, should be checked online 141 | update = await u.checkUpdates('npm'); 142 | 143 | if (!update) { 144 | t.fail('Update is undefined or false.'); 145 | return; 146 | } 147 | 148 | t.true(update.checkedOnline); 149 | }); 150 | 151 | test.serial('check for updates cached with custom package.json', async (t) => { 152 | const u = updater(); 153 | 154 | const pkg = { 155 | name: 'alfred-my-workflow', 156 | version: '1.0.0', 157 | }; 158 | 159 | // Mock requests 160 | mock.npm(2, pkg); 161 | 162 | // Check for updates 163 | let update = await u.checkUpdates('npm', pkg); 164 | 165 | if (!update) { 166 | t.fail('Update is undefined or false.'); 167 | return; 168 | } 169 | 170 | t.is(update.version, '2.0.0'); 171 | t.is(update.url, `https://www.npmjs.com/package/${pkg.name}`); 172 | t.true(update.checkedOnline); 173 | t.is(nock.pendingMocks().length, 1); 174 | 175 | // Forward time 176 | mock.forwardTime(30, 'minutes'); 177 | 178 | // Check for updates again, should be cached. 179 | update = await u.checkUpdates('npm', pkg); 180 | 181 | if (!update) { 182 | t.fail('Update is undefined or false.'); 183 | return; 184 | } 185 | 186 | t.false(update.checkedOnline); 187 | t.is(nock.pendingMocks().length, 1); 188 | 189 | // Forward time 190 | mock.forwardTime(30, 'minutes'); 191 | 192 | // Check for updates again, should be checked online 193 | update = await u.checkUpdates('npm', pkg); 194 | 195 | if (!update) { 196 | t.fail('Update is undefined or false.'); 197 | return; 198 | } 199 | 200 | t.true(update.checkedOnline); 201 | }); 202 | 203 | test('check for updates with no package name set', async (t) => { 204 | const u = updater(); 205 | 206 | await t.throwsAsync(async () => { 207 | return u.checkUpdates('npm', { 208 | version: '1.0.0', 209 | }); 210 | }, {instanceOf: Error, message: 'Invalid package.json.'}); 211 | }); 212 | 213 | test('check for updates with no package version set', async (t) => { 214 | const u = updater(); 215 | 216 | await t.throwsAsync(async () => { 217 | return u.checkUpdates('npm', { 218 | name: 'alfred-my-workflow', 219 | }); 220 | }, {instanceOf: Error, message: 'Invalid package.json.'}); 221 | }); 222 | 223 | test.serial('check for updates with unpublished package', async (t) => { 224 | const u = updater(); 225 | 226 | const pkg = readPkg.sync().packageJson; 227 | 228 | // Mock request 229 | mock.npm(1, pkg, 404); 230 | 231 | const update = await u.checkUpdates('npm'); 232 | 233 | t.is(typeof update, 'undefined'); 234 | }); 235 | 236 | test.serial('check for updates with unpublished package from custom package.json', async (t) => { 237 | const u = updater(); 238 | 239 | const pkg = { 240 | name: 'alfred-my-workflow', 241 | version: '1.0.0', 242 | }; 243 | 244 | // Mock request 245 | mock.npm(1, pkg, 404); 246 | 247 | const update = await u.checkUpdates('npm', pkg); 248 | 249 | t.is(typeof update, 'undefined'); 250 | }); 251 | 252 | test.serial('check for updates with package without latest dist-tag', async (t) => { 253 | const u = updater(); 254 | 255 | const pkg = readPkg.sync().packageJson; 256 | 257 | // Mock request 258 | nock('https://registry.npmjs.org') 259 | .get('/' + pkg.name) 260 | .reply(200, JSON.stringify({ 261 | 'name': pkg.name, 262 | 'dist-tags': {}, 263 | 'versions': { 264 | '1.0.0': { 265 | name: pkg.name, 266 | version: '1.0.0', 267 | }, 268 | '2.0.0': { 269 | name: pkg.name, 270 | version: '2.0.0', 271 | }, 272 | }, 273 | })); 274 | 275 | await t.throwsAsync(async () => { 276 | return u.checkUpdates('npm'); 277 | }, {instanceOf: Error, message: 'No latest version found in response.'}); 278 | }); 279 | 280 | test.serial('check for updates with package without latest dist-tag from custom package.json', async (t) => { 281 | const u = updater(); 282 | 283 | const pkg = { 284 | name: 'alfred-my-workflow', 285 | version: '1.0.0', 286 | }; 287 | 288 | // Mock request 289 | mock.npm(1, pkg, 200, null); 290 | 291 | await t.throwsAsync(async () => { 292 | return u.checkUpdates('npm', pkg); 293 | }, {instanceOf: Error, message: 'No latest version found in response.'}); 294 | }); 295 | 296 | test.serial('check for updates when invalid version is returned', async (t) => { 297 | const u = updater(); 298 | 299 | const pkg = readPkg.sync().packageJson; 300 | 301 | // Mock request 302 | mock.npm(1, pkg, 200, 'foobar'); 303 | 304 | await t.throwsAsync(async () => { 305 | return u.checkUpdates('npm'); 306 | }, {instanceOf: Error, message: 'Invalid version in response.'}); 307 | }); 308 | 309 | test.afterEach((t) => { 310 | if (!nock.isDone()) { 311 | t.fail('Not all requests were performed.'); 312 | } 313 | }); 314 | 315 | test.afterEach.always(() => { 316 | mock.cleanAll(); 317 | }); 318 | -------------------------------------------------------------------------------- /test/actions.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { hugo } from './helpers/init'; 3 | 4 | test.serial('no actions defined', (t) => { 5 | process.argv = [ 6 | 'node', 7 | 'index.js', 8 | 'foo', 9 | ]; 10 | 11 | const h = hugo(); 12 | 13 | t.is(h.input.length, 1); 14 | t.is(h.input[0], 'foo'); 15 | 16 | h.run(); 17 | 18 | // Now run with custom args 19 | process.argv = []; 20 | 21 | h.run(['foo']); 22 | }); 23 | 24 | test.serial('actions defined with empty name', (t) => { 25 | process.argv = [ 26 | 'node', 27 | 'index.js', 28 | ]; 29 | 30 | const h = hugo(); 31 | 32 | t.is(h.input.length, 0); 33 | 34 | t.throws(() => { 35 | h.action('', (query) => { 36 | t.log(query); 37 | t.fail(); 38 | }); 39 | }, { message: 'Action name or alias cannot be empty an empty string.' }); 40 | 41 | h.run(); 42 | }); 43 | 44 | test.serial('actions defined with empty aliases', (t) => { 45 | process.argv = [ 46 | 'node', 47 | 'index.js', 48 | ]; 49 | 50 | const h = hugo(); 51 | 52 | t.is(h.input.length, 0); 53 | 54 | t.throws(() => { 55 | t.log('foo'); 56 | h.action([], (query) => { 57 | t.log(query); 58 | t.fail(); 59 | }); 60 | }, { message: 'Action has no name or aliases.' }); 61 | 62 | h.run(); 63 | }); 64 | 65 | test.serial('actions defined but no matching action', (t) => { 66 | process.argv = [ 67 | 'node', 68 | 'index.js', 69 | 'foo', 70 | ]; 71 | 72 | const h = hugo(); 73 | 74 | h.action('bar', (query) => { 75 | t.log(query); 76 | t.fail(); 77 | }); 78 | 79 | h.action('soap', (query) => { 80 | t.log(query); 81 | t.fail(); 82 | }); 83 | 84 | h.action(['hello', 'world'], (query) => { 85 | t.log(query); 86 | t.fail(); 87 | }); 88 | 89 | t.is(h.input.length, 1); 90 | t.is(h.input[0], 'foo'); 91 | 92 | h.run(); 93 | 94 | // Now run with custom args 95 | process.argv = []; 96 | 97 | h.run(['foo']); 98 | }); 99 | 100 | test.serial('actions defined but no action given', (t) => { 101 | process.argv = [ 102 | 'node', 103 | 'index.js', 104 | ]; 105 | 106 | const h = hugo(); 107 | 108 | h.action('bar', (query) => { 109 | t.log(query); 110 | t.fail(); 111 | }); 112 | 113 | h.action('soap', (query) => { 114 | t.log(query); 115 | t.fail(); 116 | }); 117 | 118 | h.action(['hello', 'world'], (query) => { 119 | t.log(query); 120 | t.fail(); 121 | }); 122 | 123 | t.is(h.input.length, 0); 124 | 125 | h.run(); 126 | 127 | // Now run with custom args 128 | process.argv = []; 129 | 130 | h.run([]); 131 | }); 132 | 133 | test.serial('actions defined and matching action with no query', (t) => { 134 | t.plan(2); 135 | 136 | process.argv = [ 137 | 'node', 138 | 'index.js', 139 | 'foo', 140 | ]; 141 | 142 | const h = hugo(); 143 | 144 | h.action('bar', (query) => { 145 | t.log(query); 146 | t.fail(); 147 | }); 148 | 149 | h.action('foo', (query) => { 150 | t.is(query.length, 0); 151 | }); 152 | 153 | h.run(); 154 | 155 | // Now run with custom args 156 | process.argv = []; 157 | 158 | h.run(['foo']); 159 | }); 160 | 161 | test.serial('actions defined with aliases and matching action with no query', (t) => { 162 | t.plan(4); 163 | 164 | process.argv = [ 165 | 'node', 166 | 'index.js', 167 | 'foo', 168 | ]; 169 | 170 | const h = hugo(); 171 | 172 | h.action('bar', (query) => { 173 | t.log(query); 174 | t.fail(); 175 | }); 176 | 177 | h.action(['foo', 'zap'], (query) => { 178 | t.is(query.length, 0); 179 | }); 180 | 181 | t.is(h.input.length, 1); 182 | t.is(h.input[0], 'foo'); 183 | 184 | h.run(); 185 | 186 | // Now run with custom args 187 | process.argv = []; 188 | 189 | h.run(['foo']); 190 | }); 191 | 192 | test.serial('actions defined with aliases and matching alias with no query', (t) => { 193 | t.plan(4); 194 | 195 | process.argv = [ 196 | 'node', 197 | 'index.js', 198 | 'zap', 199 | ]; 200 | 201 | const h = hugo(); 202 | 203 | h.action('bar', (query) => { 204 | t.log(query); 205 | t.fail(); 206 | }); 207 | 208 | h.action(['foo', 'zap'], (query) => { 209 | t.is(query.length, 0); 210 | }); 211 | 212 | t.is(h.input.length, 1); 213 | t.is(h.input[0], 'zap'); 214 | 215 | h.run(); 216 | 217 | // Now run with custom args 218 | process.argv = []; 219 | 220 | h.run(['zap']); 221 | }); 222 | 223 | test.serial('actions defined and matching action with query', (t) => { 224 | t.plan(4); 225 | 226 | process.argv = [ 227 | 'node', 228 | 'index.js', 229 | 'foo', 230 | 'bar', 231 | ]; 232 | 233 | const h = hugo(); 234 | 235 | h.action('bar', (query) => { 236 | t.log(query); 237 | t.fail(); 238 | }); 239 | 240 | h.action('foo', (query) => { 241 | t.is(query.length, 1); 242 | t.is(query[0], 'bar'); 243 | }); 244 | 245 | h.run(); 246 | 247 | // Now run with custom args 248 | process.argv = []; 249 | 250 | h.run(['foo', 'bar']); 251 | }); 252 | 253 | test.serial('actions defined with aliases and matching action with query', (t) => { 254 | t.plan(7); 255 | 256 | process.argv = [ 257 | 'node', 258 | 'index.js', 259 | 'foo', 260 | 'bar', 261 | ]; 262 | 263 | const h = hugo(); 264 | 265 | h.action('bar', (query) => { 266 | t.log(query); 267 | t.fail(); 268 | }); 269 | 270 | h.action(['foo', 'zap'], (query) => { 271 | t.is(query.length, 1); 272 | t.is(query[0], 'bar'); 273 | }); 274 | 275 | t.is(h.input.length, 2); 276 | t.is(h.input[0], 'foo'); 277 | t.is(h.input[1], 'bar'); 278 | 279 | h.run(); 280 | 281 | // Now run with custom args 282 | process.argv = []; 283 | 284 | h.run(['foo', 'bar']); 285 | }); 286 | 287 | test.serial('actions defined with aliases and matching alias with query', (t) => { 288 | t.plan(7); 289 | 290 | process.argv = [ 291 | 'node', 292 | 'index.js', 293 | 'zap', 294 | 'bar', 295 | ]; 296 | 297 | const h = hugo(); 298 | 299 | h.action('bar', (query) => { 300 | t.log(query); 301 | t.fail(); 302 | }); 303 | 304 | h.action(['foo', 'zap'], (query) => { 305 | t.is(query.length, 1); 306 | t.is(query[0], 'bar'); 307 | }); 308 | 309 | t.is(h.input.length, 2); 310 | t.is(h.input[0], 'zap'); 311 | t.is(h.input[1], 'bar'); 312 | 313 | h.run(); 314 | 315 | // Now run with custom args 316 | process.argv = []; 317 | 318 | h.run(['zap', 'bar']); 319 | }); 320 | 321 | test.serial('main action without callback and no matching sub-action', (t) => { 322 | process.argv = [ 323 | 'node', 324 | 'index.js', 325 | 'foo', 326 | 'hello', 327 | 'world', 328 | ]; 329 | 330 | const h = hugo(); 331 | 332 | // Foo with bar sub-action 333 | h 334 | .action('foo') 335 | .action('bar', (query) => { 336 | t.fail(); 337 | }) 338 | ; 339 | 340 | t.is(h.input.length, 3); 341 | t.is(h.input[0], 'foo'); 342 | t.is(h.input[1], 'hello'); 343 | t.is(h.input[2], 'world'); 344 | 345 | h.run(); 346 | 347 | // Now run with custom args 348 | process.argv = []; 349 | 350 | h.run(['foo', 'hello', 'world']); 351 | }); 352 | 353 | test.serial('main action with matching sub-action', (t) => { 354 | t.plan(11); 355 | 356 | process.argv = [ 357 | 'node', 358 | 'index.js', 359 | 'foo', 360 | 'bar', 361 | 'hello', 362 | 'world', 363 | ]; 364 | 365 | const h = hugo(); 366 | 367 | // Foo action with bar sub-action 368 | const fooAction = h.action('foo', (query) => { 369 | // Fail if calling just foo without sub action 370 | t.fail(); 371 | }); 372 | 373 | // Bar sub-action with floop sub-action 374 | const barAction = fooAction.action('bar', (query) => { 375 | t.is(query.length, 2); 376 | t.is(query[0], 'hello'); 377 | t.is(query[1], 'world'); 378 | }); 379 | 380 | barAction.action('floop', (query) => { 381 | t.fail(); 382 | }); 383 | 384 | t.is(h.input.length, 4); 385 | t.is(h.input[0], 'foo'); 386 | t.is(h.input[1], 'bar'); 387 | t.is(h.input[2], 'hello'); 388 | t.is(h.input[3], 'world'); 389 | 390 | h.run(); 391 | 392 | // Now run with custom args 393 | process.argv = []; 394 | 395 | h.run(['foo', 'bar', 'hello', 'world']); 396 | }); 397 | 398 | test.serial('main action with matching sub-action by alias', (t) => { 399 | t.plan(11); 400 | 401 | process.argv = [ 402 | 'node', 403 | 'index.js', 404 | 'foo', 405 | 'zap', 406 | 'hello', 407 | 'world', 408 | ]; 409 | 410 | const h = hugo(); 411 | 412 | // Foo action with bar sub-action 413 | const fooAction = h.action('foo', (query) => { 414 | // Fail if calling just foo without sub action 415 | t.fail(); 416 | }); 417 | 418 | // Bar sub-action with floop sub-action 419 | const barAction = fooAction.action(['bar', 'zap'], (query) => { 420 | t.is(query.length, 2); 421 | t.is(query[0], 'hello'); 422 | t.is(query[1], 'world'); 423 | }); 424 | 425 | barAction.action('floop', (query) => { 426 | t.fail(); 427 | }); 428 | 429 | t.is(h.input.length, 4); 430 | t.is(h.input[0], 'foo'); 431 | t.is(h.input[1], 'zap'); 432 | t.is(h.input[2], 'hello'); 433 | t.is(h.input[3], 'world'); 434 | 435 | h.run(); 436 | 437 | // Now run with custom args 438 | process.argv = []; 439 | 440 | h.run(['foo', 'zap', 'hello', 'world']); 441 | }); 442 | 443 | test.serial('main action by alias with matching sub-action', (t) => { 444 | t.plan(11); 445 | 446 | process.argv = [ 447 | 'node', 448 | 'index.js', 449 | 'bleep', 450 | 'bar', 451 | 'hello', 452 | 'world', 453 | ]; 454 | 455 | const h = hugo(); 456 | 457 | // Foo action with bar sub-action 458 | const fooAction = h.action(['foo', 'bleep'], (query) => { 459 | // Fail if calling just foo without sub action 460 | t.fail(); 461 | }); 462 | 463 | // Bar sub-action with floop sub-action 464 | const barAction = fooAction.action(['bar', 'zap'], (query) => { 465 | t.is(query.length, 2); 466 | t.is(query[0], 'hello'); 467 | t.is(query[1], 'world'); 468 | }); 469 | 470 | barAction.action('floop', (query) => { 471 | t.fail(); 472 | }); 473 | 474 | t.is(h.input.length, 4); 475 | t.is(h.input[0], 'bleep'); 476 | t.is(h.input[1], 'bar'); 477 | t.is(h.input[2], 'hello'); 478 | t.is(h.input[3], 'world'); 479 | 480 | h.run(); 481 | 482 | // Now run with custom args 483 | process.argv = []; 484 | 485 | h.run(['bleep', 'bar', 'hello', 'world']); 486 | }); 487 | 488 | test.serial('main action with matching sub-sub-action', (t) => { 489 | t.plan(12); 490 | 491 | process.argv = [ 492 | 'node', 493 | 'index.js', 494 | 'foo', 495 | 'bar', 496 | 'floop', 497 | 'hello', 498 | 'world', 499 | ]; 500 | 501 | const h = hugo(); 502 | 503 | // Foo action with bar sub-action 504 | const fooAction = h.action('foo', (query) => { 505 | t.fail(); 506 | }); 507 | 508 | // Bar sub-action with floop sub-action 509 | const barAction = fooAction.action('bar', (query) => { 510 | t.fail(); 511 | }); 512 | 513 | barAction.action('floop', (query) => { 514 | t.is(query.length, 2); 515 | t.is(query[0], 'hello'); 516 | t.is(query[1], 'world'); 517 | }); 518 | 519 | t.is(h.input.length, 5); 520 | t.is(h.input[0], 'foo'); 521 | t.is(h.input[1], 'bar'); 522 | t.is(h.input[2], 'floop'); 523 | t.is(h.input[3], 'hello'); 524 | t.is(h.input[4], 'world'); 525 | 526 | h.run(); 527 | 528 | // Now run with custom args 529 | process.argv = []; 530 | 531 | h.run(['foo', 'bar', 'floop', 'hello', 'world']); 532 | }); 533 | 534 | test.serial('main action with matching sub-sub-action by alias', (t) => { 535 | t.plan(12); 536 | 537 | process.argv = [ 538 | 'node', 539 | 'index.js', 540 | 'foo', 541 | 'bar', 542 | 'flap', 543 | 'hello', 544 | 'world', 545 | ]; 546 | 547 | const h = hugo(); 548 | 549 | // Foo action with bar sub-action 550 | const fooAction = h.action('foo', (query) => { 551 | t.fail(); 552 | }); 553 | 554 | // Bar sub-action with floop sub-action 555 | const barAction = fooAction.action('bar', (query) => { 556 | t.fail(); 557 | }); 558 | 559 | barAction.action(['floop', 'flap'], (query) => { 560 | t.is(query.length, 2); 561 | t.is(query[0], 'hello'); 562 | t.is(query[1], 'world'); 563 | }); 564 | 565 | t.is(h.input.length, 5); 566 | t.is(h.input[0], 'foo'); 567 | t.is(h.input[1], 'bar'); 568 | t.is(h.input[2], 'flap'); 569 | t.is(h.input[3], 'hello'); 570 | t.is(h.input[4], 'world'); 571 | 572 | h.run(); 573 | 574 | // Now run with custom args 575 | process.argv = []; 576 | 577 | h.run(['foo', 'bar', 'flap', 'hello', 'world']); 578 | }); 579 | 580 | test.serial('main action with sub-actions defined, without query', (t) => { 581 | t.plan(2); 582 | 583 | process.argv = [ 584 | 'node', 585 | 'index.js', 586 | 'foo', 587 | ]; 588 | 589 | const h = hugo(); 590 | 591 | // Foo with bar sub-action 592 | h 593 | .action('foo', (query) => { 594 | t.is(query.length, 0); 595 | }) 596 | .action('bar', (query) => { 597 | t.fail(); 598 | }) 599 | ; 600 | 601 | h.run(); 602 | 603 | // Now run with custom args 604 | process.argv = []; 605 | 606 | h.run(['foo']); 607 | }); 608 | 609 | test.serial('main action with sub-actions defined, with query but no sub-action match', (t) => { 610 | t.plan(4); 611 | 612 | process.argv = [ 613 | 'node', 614 | 'index.js', 615 | 'foo', 616 | 'hello', 617 | ]; 618 | 619 | const h = hugo(); 620 | 621 | // Foo with bar sub-action 622 | h 623 | .action('foo', (query) => { 624 | t.is(query.length, 1); 625 | t.is(query[0], 'hello'); 626 | }) 627 | .action('bar', (query) => { 628 | t.fail(); 629 | }) 630 | ; 631 | 632 | h.run(); 633 | 634 | // Now run with custom args 635 | process.argv = []; 636 | 637 | h.run(['foo', 'hello']); 638 | }); 639 | -------------------------------------------------------------------------------- /src/hugo.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import crypto from 'crypto'; 3 | import Fuse from 'fuse.js'; 4 | import Axios, { AxiosRequestConfig } from 'axios'; 5 | import moment from 'moment'; 6 | import path from 'path'; 7 | import semver from 'semver'; 8 | import NotificationCenter from 'node-notifier/notifiers/notificationcenter'; 9 | import { Cache, ICacheOptions } from '@cloudstek/cache'; 10 | 11 | import { Action } from './action'; 12 | import { FileCache } from './file-cache'; 13 | import { Updater } from './updater'; 14 | import utils from './utils'; 15 | import { AlfredMeta, FilterResults, HugoOptions, WorkflowMeta, UpdateSource, Item } from './types'; 16 | 17 | export class Hugo { 18 | public cache: Cache; 19 | public config: Cache; 20 | public rerun: number; 21 | public variables: { [key: string]: any } = {}; 22 | public items: Item[] = []; 23 | 24 | private readonly actions: Action[]; 25 | private readonly fuseDefaults: Fuse.FuseOptions; 26 | private options: HugoOptions; 27 | private readonly updater: Updater; 28 | private readonly notifier: NotificationCenter; 29 | 30 | public constructor(options?: HugoOptions) { 31 | // Save options 32 | this.options = { 33 | checkUpdates: true, 34 | updateInterval: moment.duration(1, 'day'), 35 | updateItem: true, 36 | updateNotification: true, 37 | updateSource: UpdateSource.NPM, 38 | }; 39 | 40 | this.configure(options || {}); 41 | 42 | // Set defaults for FuseJS 43 | this.fuseDefaults = { 44 | keys: ['match'], 45 | threshold: 0.4, 46 | }; 47 | 48 | // Configure config store 49 | this.config = new Cache({ 50 | dir: this.workflowMeta.data, 51 | name: 'config.json', 52 | ttl: false, 53 | }); 54 | 55 | // Configure cache store 56 | this.cache = new Cache({ 57 | dir: this.workflowMeta.cache, 58 | }); 59 | 60 | // Initialize updater 61 | this.updater = new Updater(this.cache, this.options.updateInterval); 62 | 63 | // Notifier 64 | this.notifier = new NotificationCenter({ 65 | withFallback: true, 66 | }); 67 | 68 | // Actions 69 | this.actions = []; 70 | } 71 | 72 | /** 73 | * Set Hugo options 74 | * 75 | * @param options Options to set 76 | * 77 | * @return Hugo 78 | */ 79 | public configure(options: HugoOptions): Hugo { 80 | // Update options 81 | options = Object.assign({}, this.options, options); 82 | 83 | // Convert updateInterval to moment.Duration object 84 | if (options.updateInterval) { 85 | if (!moment.isDuration(options.updateInterval)) { 86 | options.updateInterval = moment.duration(options.updateInterval, 'seconds'); 87 | } 88 | } 89 | 90 | if (!options.updateInterval || (options.updateInterval as moment.Duration).asSeconds() < 1) { 91 | options.checkUpdates = false; 92 | delete options.updateInterval; 93 | } 94 | 95 | if (typeof options.updateSource !== 'string' || !UpdateSource[options.updateSource.toLowerCase() as any]) { 96 | throw new Error('Invalid update source.'); 97 | } 98 | 99 | this.options = options; 100 | 101 | return this; 102 | } 103 | 104 | /** 105 | * Alfred metadata 106 | * 107 | * @return AlfredMeta 108 | */ 109 | public get alfredMeta(): AlfredMeta { 110 | let version = semver.valid(semver.coerce(process.env.alfred_version)); 111 | 112 | // Check if version is valid 113 | if (version === null) { 114 | if (process.env.alfred_debug === '1') { 115 | console.error(`Invalid Alfred version: ${process.env.alfred_version}`); 116 | } 117 | 118 | version = undefined; 119 | } 120 | 121 | // Gather environment information 122 | const data: AlfredMeta = { 123 | debug: process.env.alfred_debug === '1', 124 | preferences: process.env.alfred_preferences || utils.resolveAlfredPrefs(version), 125 | preferencesLocalHash: process.env.alfred_preferences_localhash, 126 | theme: process.env.alfred_theme, 127 | themeBackground: process.env.alfred_theme_background, 128 | themeSelectionBackground: process.env.alfred_theme_selection_background, 129 | themeSubtext: parseFloat(process.env.alfred_theme_subtext || '0'), 130 | version, 131 | }; 132 | 133 | // Find and load curent Alfred theme file 134 | if (process.env.HOME && data.theme) { 135 | const themeFile = path.resolve(data.preferences, 'themes', data.theme, 'theme.json'); 136 | 137 | try { 138 | fs.statSync(themeFile); 139 | data.themeFile = themeFile; 140 | } catch (e) { 141 | if (process.env.alfred_debug === '1') { 142 | console.error(`Could not find theme file "${themeFile}"`); 143 | } 144 | } 145 | } 146 | 147 | return data; 148 | } 149 | 150 | /** 151 | * Alfred theme 152 | * 153 | * @return any | null 154 | */ 155 | public get alfredTheme(): any | null { 156 | const themeFile = this.alfredMeta.themeFile; 157 | 158 | if (!themeFile || utils.fileExists(themeFile) === false) { 159 | return null; 160 | } 161 | 162 | const theme = fs.readJsonSync(themeFile); 163 | 164 | return theme.alfredtheme; 165 | } 166 | 167 | /** 168 | * Workflow metadata 169 | * 170 | * @return WorkflowMeta 171 | */ 172 | public get workflowMeta(): WorkflowMeta { 173 | let version = semver.valid(semver.coerce(process.env.alfred_workflow_version)); 174 | 175 | // Check if version is valid 176 | if (version === null) { 177 | if (process.env.alfred_debug === '1') { 178 | // eslint-disable-next-line max-len 179 | console.error(`Invalid workflow version: ${process.env.alfred_workflow_version}. Open your workflow in Alfred, click on the [x]-Symbol and set a semantic version number.`); 180 | } 181 | 182 | version = undefined; 183 | } 184 | 185 | return { 186 | bundleId: process.env.alfred_workflow_bundleid, 187 | cache: process.env.alfred_workflow_cache, 188 | data: process.env.alfred_workflow_data, 189 | icon: path.join(process.cwd(), 'icon.png'), 190 | name: process.env.alfred_workflow_name, 191 | uid: process.env.alfred_workflow_uid, 192 | version, 193 | }; 194 | } 195 | 196 | /** 197 | * Reset Hugo. 198 | * 199 | * @return Hugo 200 | */ 201 | public reset(): Hugo { 202 | this.rerun = undefined; 203 | this.variables = {}; 204 | this.items = []; 205 | 206 | return this; 207 | } 208 | 209 | /** 210 | * Alfred user input 211 | * 212 | * @return string[] 213 | */ 214 | public get input(): string[] { 215 | return process.argv.slice(2); 216 | } 217 | 218 | /** 219 | * Current output buffer 220 | * 221 | * @see https://www.alfredapp.com/help/workflows/inputs/script-filter/json 222 | * 223 | * @return FilterResults to be output and interpreted by Alfred 224 | */ 225 | public get output(): FilterResults { 226 | if (this.rerun !== null && (this.rerun < 0.1 || this.rerun > 5.0)) { 227 | throw new Error('Invalid value for rerun, must be between 0.1 and 5.0'); 228 | } 229 | 230 | return { 231 | rerun: this.rerun, 232 | items: this.items, 233 | variables: this.variables, 234 | }; 235 | } 236 | 237 | /** 238 | * Run a callback when first script argument matches keyword. Callback wil have remaining arguments as argument. 239 | * 240 | * @example node index.js myaction "my query" 241 | * 242 | * @param name Action name and optionally aliases 243 | * @param callback Callback to execute when query matches action name 244 | * 245 | * @return Action 246 | */ 247 | public action( 248 | name: string|string[], 249 | callback?: (args: string[]) => void, 250 | ): Action { 251 | const action = new Action(name, callback); 252 | 253 | this.actions.push(action); 254 | 255 | return action; 256 | } 257 | 258 | /** 259 | * Find defined action from arguments and run it. 260 | * 261 | * @param args 262 | * 263 | * @return Hugo 264 | */ 265 | public run(args?: string[]): Hugo { 266 | if (!args) { 267 | args = process.argv.slice(2); 268 | } 269 | 270 | for (const action of this.actions) { 271 | if (action.run(args) === true) { 272 | break; 273 | } 274 | } 275 | 276 | return this; 277 | } 278 | 279 | /** 280 | * Cache processed file. 281 | * 282 | * This allows you to read and process the data once, then storing it in cache until the file has changed again. 283 | * 284 | * @param filePath File path 285 | * @param options Cache options 286 | * 287 | * @return FileCache 288 | */ 289 | public cacheFile(filePath: string, options?: ICacheOptions): FileCache { 290 | return new FileCache(filePath, options || { 291 | dir: this.workflowMeta.cache, 292 | }); 293 | } 294 | 295 | /** 296 | * Clear cache 297 | * 298 | * Clear the whole workflow cache directory. 299 | * 300 | * @return Promise 301 | */ 302 | public async clearCache(): Promise { 303 | if (this.workflowMeta.cache) { 304 | return fs.emptyDir(this.workflowMeta.cache); 305 | } 306 | } 307 | 308 | /** 309 | * Clear cache 310 | * 311 | * Clear the whole workflow cache directory. 312 | */ 313 | public clearCacheSync(): void { 314 | if (this.workflowMeta.cache) { 315 | fs.emptyDirSync(this.workflowMeta.cache); 316 | } 317 | } 318 | 319 | /** 320 | * Filter list of candidates with fuse.js 321 | * 322 | * @see http://fusejs.io 323 | * 324 | * @param {Array.} candidates Input data 325 | * @param {string} query Search string 326 | * @param {Object} options fuse.js options 327 | * 328 | * @return Item[] 329 | */ 330 | public match(candidates: Item[], query: string, options?: Fuse.FuseOptions): Item[] { 331 | options = Object.assign({}, this.fuseDefaults, options || {}); 332 | 333 | if (query.trim().length === 0) { 334 | return candidates; 335 | } 336 | 337 | // Set match attribute to title when missing to mimic Alfred matching behaviour 338 | for (const key of options.keys) { 339 | const name = typeof key === 'string' ? key : key.name; 340 | 341 | if (name === 'match') { 342 | candidates = candidates.map((candidate) => { 343 | if (!candidate.match) { 344 | candidate.match = candidate.title; 345 | } 346 | 347 | return candidate; 348 | }); 349 | 350 | break; 351 | } 352 | } 353 | 354 | // Make sure to always return Item[] 355 | options.id = undefined; 356 | options.includeMatches = false; 357 | options.includeScore = false; 358 | 359 | // Create fuse.js fuzzy search object 360 | const fuse = new Fuse(candidates, options); 361 | 362 | // Return results 363 | return fuse.search(query) as Item[]; 364 | } 365 | 366 | /** 367 | * Send a notification 368 | * 369 | * Notification title defaults to the Workflow name, or when not available to 'Alfred'. 370 | * You can adjust all the options that node-notifier supports. Please see their documentation for available options. 371 | * 372 | * @see https://github.com/mikaelbr/node-notifier 373 | * 374 | * @param notification Notification options 375 | * 376 | * @return Promise 377 | */ 378 | public async notify(notification: NotificationCenter.Notification): Promise { 379 | return new Promise((resolve, reject) => { 380 | const defaults: NotificationCenter.Notification = { 381 | contentImage: this.workflowMeta.icon, 382 | title: ('Alfred ' + this.workflowMeta.name).trim(), 383 | }; 384 | 385 | // Set options 386 | notification = Object.assign({}, defaults, notification); 387 | 388 | // Notify 389 | this.notifier.notify(notification, (err, response) => { 390 | if (err) { 391 | reject(err); 392 | return; 393 | } 394 | 395 | resolve(response); 396 | }); 397 | }); 398 | } 399 | 400 | /** 401 | * Check for updates and notify the user 402 | * 403 | * @param pkg Package.json contents. When undefined, will read from file. 404 | * 405 | * @return Promise 406 | */ 407 | public async checkUpdates(pkg?: any): Promise { 408 | // No need to check if we're not showing anything, duh. 409 | if (this.options.checkUpdates !== true || 410 | (this.options.updateItem !== true && this.options.updateNotification !== true)) { 411 | return; 412 | } 413 | 414 | await this.updater.checkUpdates(this.options.updateSource as string, pkg) 415 | .then((result) => { 416 | if (!result) { 417 | return; 418 | } 419 | 420 | // Version information 421 | const current = this.workflowMeta.version; 422 | const latest = result.version; 423 | 424 | if (!current) { 425 | return; 426 | } 427 | 428 | // Display notification 429 | if (semver.gt(latest, current)) { 430 | if (result.checkedOnline === true && this.options.updateNotification === true) { 431 | this.notify({ 432 | message: `Workflow version ${latest} available. Current version: ${current}.`, 433 | }); 434 | } 435 | if (this.options.updateItem === true) { 436 | // Make sure update item is only added once 437 | this.items = this.items.filter((item) => { 438 | return item.title !== 'Workflow update available!'; 439 | }); 440 | 441 | this.items.push({ 442 | title: 'Workflow update available!', 443 | subtitle: `Version ${latest} is available. Current version: ${current}.`, 444 | icon: { 445 | path: this.workflowMeta.icon, 446 | }, 447 | arg: result.url, 448 | variables: { 449 | task: 'wfUpdate', 450 | }, 451 | }); 452 | } 453 | } 454 | }) 455 | .catch((err) => { 456 | if (process.env.alfred_debug === '1') { 457 | console.error(err.message); 458 | } 459 | return; 460 | }); 461 | } 462 | 463 | /** 464 | * Fetch url and parse JSON. Useful for REST APIs. 465 | * 466 | * @see https://www.npmjs.com/package/got 467 | * 468 | * @param url Url to request 469 | * @param options http.request options 470 | * @param ttl Cache lifetime (in seconds). Undefined to disable or false to enable indefinite caching. 471 | * 472 | * @return Promise 473 | */ 474 | public async fetch(url: string, options?: AxiosRequestConfig, ttl?: number | false): Promise { 475 | const urlHash = crypto.createHash('md5').update(url).digest('hex'); 476 | 477 | // Check cache for a hit 478 | if (ttl && ttl > 0) { 479 | if (this.cache.has(urlHash)) { 480 | return this.cache.get(urlHash); 481 | } 482 | } 483 | 484 | // Do request 485 | return Axios.get(url, options) 486 | .then((response) => { 487 | if (ttl && ttl > 0) { 488 | this.cache.set(urlHash, response.data, ttl); 489 | this.cache.commit(); 490 | } 491 | 492 | return response.data; 493 | }); 494 | } 495 | 496 | /** 497 | * Flush the output buffer so Alfred shows our items 498 | * 499 | * @return Promise 500 | */ 501 | public async feedback(): Promise { 502 | // Check for updates 503 | if (this.options.checkUpdates === true) { 504 | await this.checkUpdates(); 505 | } 506 | 507 | const output = this.output; 508 | 509 | // Output JSON 510 | console.log(JSON.stringify(output, null, '\t')); 511 | 512 | // Reset everything 513 | this.reset(); 514 | } 515 | } 516 | --------------------------------------------------------------------------------