diff --git a/assets/info.plist b/assets/info.plist
index 2efe9ee6..9e2b060c 100644
--- a/assets/info.plist
+++ b/assets/info.plist
@@ -96,7 +96,7 @@
argumenttype
1
escaping
- 36
+ 102
keyword
todos
queuedelaycustom
@@ -119,8 +119,7 @@
. ./check-node.sh
-escaped="$(echo "{query}" | sed 's/"/\\"/g')"
-node $node_flags alfred-workflow-todoist.js "{\"name\": \"read\", \"args\": \"$escaped\"}"
+node $node_flags alfred-workflow-todoist.js "{\"name\": \"read\", \"args\": \"{query}\"}"
scriptargtype
0
@@ -219,7 +218,7 @@ node $node_flags alfred-workflow-todoist.js "{query}"
argumenttype
0
escaping
- 36
+ 102
keyword
todo
queuedelaycustom
@@ -242,8 +241,7 @@ node $node_flags alfred-workflow-todoist.js "{query}"
. ./check-node.sh
-escaped="$(echo "{query}" | sed 's/"/\\"/g')"
-node $node_flags alfred-workflow-todoist.js "{\"name\": \"parse\", \"args\": \"$escaped\"}"
+node $node_flags alfred-workflow-todoist.js "{\"name\": \"parse\", \"args\": \"{query}\"}"
scriptargtype
0
@@ -294,7 +292,7 @@ node $node_flags alfred-workflow-todoist.js "{\"name\": \"parse\", \"args\": \"$
argumenttype
1
escaping
- 38
+ 102
keyword
todo:setting
queuedelaycustom
@@ -317,8 +315,7 @@ node $node_flags alfred-workflow-todoist.js "{\"name\": \"parse\", \"args\": \"$
. ./check-node.sh
-escaped="$(echo "{query}" | sed 's/"/\\"/g')"
-node $node_flags alfred-workflow-todoist.js "{\"name\": \"readSettings\", \"args\": \"$escaped\"}"
+node $node_flags alfred-workflow-todoist.js "{\"name\": \"readSettings\", \"args\": \"{query}\"}"
scriptargtype
0
@@ -412,7 +409,7 @@ node $node_flags alfred-workflow-todoist.js "{\"name\": \"readSettings\", \"args
version
- 0.0.0-development
+ 6.0.0-alpha.8
webaddress
https://github.com/moranje/alfred-workflow-todoist
diff --git a/jest.config.js b/jest.config.js
index 3c6497f2..e58bd6f9 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -33,7 +33,6 @@ module.exports = {
'/dist/',
'/tools/',
'alfred-workflow-todoist.ts',
- 'cli-args',
],
// A list of reporter names that Jest uses when writing coverage reports
diff --git a/src/lib/cli-args.ts b/src/lib/cli-args.ts
index adb80123..6ce6760a 100755
--- a/src/lib/cli-args.ts
+++ b/src/lib/cli-args.ts
@@ -1,9 +1,10 @@
-import { isPrimitive } from 'util';
import { TodoistTask, TodoistTaskOptions } from 'todoist-rest-api';
+import { isPrimitive } from 'util';
-import { ResourceName } from './todoist/local-rest-adapter';
import { Settings } from '@/lib/stores/settings-store';
+import { ResourceName } from './todoist/local-rest-adapter';
+
type Arg =
| string
| TodoistTaskOptions
@@ -39,6 +40,16 @@ export type Call =
};
const [, , ...argv] = process.argv;
+export const callNames: { [key: string]: boolean } = {
+ parse: true,
+ read: true,
+ readSettings: true,
+ openUrl: true,
+ create: true,
+ remove: true,
+ writeSetting: true,
+ refreshCache: true,
+};
function assertValidArgs(args: Arg): asserts args is Arg {
if (args == null) {
@@ -49,13 +60,22 @@ function assertValidArgs(args: Arg): asserts args is Arg {
}
function assertValidCall(call: Call): asserts call is Call {
+ // istanbul ignore next: shouldn't be possible in codebase
if (!call || isPrimitive(call)) {
throw new TypeError(`The call should be a an object, was ${call}`);
}
if (!call.name || typeof call.name !== 'string') {
throw new TypeError(
- `Expected call.name to be a string was ${call.name} (${typeof call.name})`
+ `Expected call.name to be a string was ${
+ call.name
+ } (of type ${typeof call.name})`
+ );
+ }
+
+ if (!callNames[call.name.toString()]) {
+ throw new TypeError(
+ `Expected call.name to be one of parse, read, readSettings, openUrl, create, remove, writeSetting, refreshCache, was ${call.name}`
);
}
@@ -68,22 +88,49 @@ function serialize(call: Call): string | never {
return JSON.stringify(call);
}
+function escape(string: string): string {
+ // @ts-ignore: is valid
+ return String(string).replace(/["\\\b\f\n\r\t]/g, char => {
+ switch (char) {
+ case '"':
+ return '\\"';
+ case '\b':
+ return '\\b';
+ case '\f':
+ return '\\f';
+ case '\n':
+ return '\\n';
+ case '\r':
+ return '\\r';
+ case '\t':
+ return '\\t';
+ default:
+ return char;
+ }
+ });
+}
+
function deserialize(serialized: string): Call | never {
- if (typeof serialized !== 'string') {
- throw new TypeError(
- `Expected a string in deserialize, got ${serialized} (${typeof serialized})`
- );
- }
+ const escaped = serialized.replace(
+ /"args": "([\s\S]+?)"}/,
+ (match, input) => {
+ return `"args": "${escape(input)}"}`;
+ }
+ );
try {
- const call = JSON.parse(serialized) as Call;
+ const call = JSON.parse(escaped) as Call;
assertValidCall(call);
return call;
} catch (error) {
- throw new TypeError(
- `Expected a JSON string, got '${serialized}' (${typeof serialized})`
- );
+ if (error instanceof SyntaxError) {
+ throw new TypeError(
+ `Expected a JSON string, got '${escaped}' (${typeof escaped})`
+ );
+ }
+
+ throw new TypeError(error.message);
}
}
@@ -116,7 +163,7 @@ export function isUserFacingCall(): boolean {
let call;
try {
call = getCurrentCall();
- } catch {
+ } catch (error) {
// Err on the side of caution.
return true;
}
diff --git a/src/lib/tests/cli-args.ts b/src/lib/tests/cli-args.ts
new file mode 100644
index 00000000..b783daed
--- /dev/null
+++ b/src/lib/tests/cli-args.ts
@@ -0,0 +1,176 @@
+import mockArgv from 'mock-argv';
+// import { createCall } from '@/lib/cli-args';
+
+type CallName =
+ | 'parse'
+ | 'read'
+ | 'readSettings'
+ | 'openUrl'
+ | 'create'
+ | 'remove'
+ | 'writeSetting'
+ | 'refreshCache'
+ | 'anything'; // for testing purposes
+
+function escape(string: string): string {
+ return String(string).replace(/[\\]/g, function(char) {
+ switch (char) {
+ // case '"':
+ // return '\\"';
+ case '\\':
+ return '\\' + char;
+ }
+ });
+}
+
+function cliArgs(callName: CallName, input: any): string {
+ return `{"name": "${callName}", "args": "${escape(input.toString())}"}`;
+}
+
+describe('unit: CLI', () => {
+ beforeEach(() => jest.resetModules());
+
+ it('should escape characters invalid to JSON during deserialization', async () => {
+ expect.assertions(1);
+
+ const characters = '\\ " \t \n \r \b \f';
+ await mockArgv([cliArgs('read', characters)], async () => {
+ const { getCurrentCall } = await import('@/lib/cli-args');
+
+ const cli = getCurrentCall();
+
+ expect(cli.args).toBe(characters);
+ });
+ });
+
+ it('should throw if call is not a valid JSON string', async () => {
+ expect.assertions(1);
+
+ await mockArgv([{}], async () => {
+ const { getCurrentCall } = await import('@/lib/cli-args');
+
+ expect(() => {
+ const cli = getCurrentCall();
+ }).toThrow(/Expected a JSON string/);
+ });
+ });
+
+ it("should throw if call doesn't have a name property", async () => {
+ expect.assertions(1);
+
+ await mockArgv(['{}'], async () => {
+ const { getCurrentCall } = await import('@/lib/cli-args');
+
+ expect(() => {
+ const cli = getCurrentCall();
+ }).toThrow(/Expected call.name to be a string/);
+ });
+ });
+
+ it("should throw if call doesn't have an args property", async () => {
+ expect.assertions(1);
+
+ await mockArgv(['{"name": "read"}'], async () => {
+ const { getCurrentCall } = await import('@/lib/cli-args');
+
+ expect(() => {
+ const cli = getCurrentCall();
+ }).toThrow(/Property args should not be null or undefined/);
+ });
+ });
+
+ it('should serialize to a JSON parsable string', async () => {
+ expect.assertions(1);
+
+ const { createCall } = await import('@/lib/cli-args');
+
+ const characters = '\\ " \t \n \r \b \f';
+ const call = createCall({
+ name: 'create',
+ args: characters,
+ });
+
+ expect(JSON.parse(call).args).toBe(characters);
+ });
+
+ it('should label "parse" as a user facing call', async () => {
+ expect.assertions(1);
+
+ await mockArgv([cliArgs('parse', '')], async () => {
+ const { isUserFacingCall } = await import('@/lib/cli-args');
+ expect(isUserFacingCall()).toBe(true);
+ });
+ });
+
+ it('should label "read" as a user facing call', async () => {
+ expect.assertions(1);
+
+ await mockArgv([cliArgs('read', '')], async () => {
+ const { isUserFacingCall } = await import('@/lib/cli-args');
+ expect(isUserFacingCall()).toBe(true);
+ });
+ });
+
+ it('should label "readSettings" as a user facing call', async () => {
+ expect.assertions(1);
+
+ await mockArgv([cliArgs('readSettings', '')], async () => {
+ const { isUserFacingCall } = await import('@/lib/cli-args');
+ expect(isUserFacingCall()).toBe(true);
+ });
+ });
+
+ it('should label "openUrl" as non-user facing call', async () => {
+ expect.assertions(1);
+
+ await mockArgv([cliArgs('openUrl', '')], async () => {
+ const { isUserFacingCall } = await import('@/lib/cli-args');
+ expect(isUserFacingCall()).toBe(false);
+ });
+ });
+
+ it('should label "create" as non-user facing call', async () => {
+ expect.assertions(1);
+
+ await mockArgv([cliArgs('create', '')], async () => {
+ const { isUserFacingCall } = await import('@/lib/cli-args');
+ expect(isUserFacingCall()).toBe(false);
+ });
+ });
+
+ it('should label "remove" as non-user facing call', async () => {
+ expect.assertions(1);
+
+ await mockArgv([cliArgs('remove', '')], async () => {
+ const { isUserFacingCall } = await import('@/lib/cli-args');
+ expect(isUserFacingCall()).toBe(false);
+ });
+ });
+
+ it('should label "writeSetting" as non-user facing call', async () => {
+ expect.assertions(1);
+
+ await mockArgv([cliArgs('writeSetting', '')], async () => {
+ const { isUserFacingCall } = await import('@/lib/cli-args');
+ expect(isUserFacingCall()).toBe(false);
+ });
+ });
+
+ it('should label "refreshCache" as non-user facing call', async () => {
+ expect.assertions(1);
+
+ await mockArgv([cliArgs('refreshCache', '')], async () => {
+ const { isUserFacingCall } = await import('@/lib/cli-args');
+ expect(isUserFacingCall()).toBe(false);
+ });
+ });
+
+ it('should label unknown calls as user facing', async () => {
+ expect.assertions(1);
+
+ await mockArgv([cliArgs('anything', '')], async () => {
+ const { isUserFacingCall } = await import('@/lib/cli-args');
+ expect(isUserFacingCall()).toBe(true);
+ });
+ });
+});