├── .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 | 
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 | # 
2 |
3 | [](https://circleci.com/gh/Cloudstek/alfred-hugo) [](https://coveralls.io/github/Cloudstek/alfred-hugo?branch=master) [](https://github.com/Cloudstek/alfred-hugo/issues)   
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 |
--------------------------------------------------------------------------------
/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.