307 lines
11 KiB
JavaScript
307 lines
11 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
// CLI wrapper around MSTodoClient. Prints JSON to stdout; errors to stderr with a
|
||
|
|
// non-zero exit so callers can detect failure reliably.
|
||
|
|
//
|
||
|
|
// Auth is DELEGATED (auth-code + PKCE, browser) — Microsoft To Do has no app-only
|
||
|
|
// access. Run `login` once; it opens your browser. The refresh token is then
|
||
|
|
// cached and reused automatically thereafter.
|
||
|
|
//
|
||
|
|
// Config (env or a .env file in the CWD or this skill's directory):
|
||
|
|
// TODO_CLIENT_ID (or AZURE_CLIENT_ID) required — a PUBLIC client app id
|
||
|
|
// TODO_TENANT_ID (or AZURE_TENANT_ID) optional — default "common"
|
||
|
|
// TODO_TOKEN_CACHE optional — path to the token cache (default: .token-cache.json here)
|
||
|
|
// TODO_REDIRECT_PORT optional — pin the loopback redirect port (default: OS-assigned)
|
||
|
|
// TODO_TIMEZONE optional — IANA/Windows zone for --due/--start/--reminder (default UTC)
|
||
|
|
//
|
||
|
|
// Usage:
|
||
|
|
// node todo.mjs login Sign in (opens browser), once
|
||
|
|
// node todo.mjs logout Forget the cached sign-in
|
||
|
|
// node todo.mjs test Verify auth; show signed-in user
|
||
|
|
// node todo.mjs lists List task lists
|
||
|
|
// node todo.mjs list-create --name "Groceries"
|
||
|
|
// node todo.mjs list-update --list NAME_OR_ID --name "New name"
|
||
|
|
// node todo.mjs list-delete --list NAME_OR_ID
|
||
|
|
// node todo.mjs tasks --list NAME_OR_ID [--filter ODATA] [--select F1,F2] [--orderby "dueDateTime/dateTime asc"] [--top N] [--all]
|
||
|
|
// node todo.mjs get --list NAME_OR_ID --id TASK_ID
|
||
|
|
// node todo.mjs create --list NAME_OR_ID --title "..." [--due 2026-07-01] [--start ...] [--reminder ...] [--importance high] [--body "..."] [--fields JSON]
|
||
|
|
// node todo.mjs update --list NAME_OR_ID --id TASK_ID [--title ...] [--status completed] [--due ...] [--importance ...] [--body ...] [--fields JSON]
|
||
|
|
// node todo.mjs done --list NAME_OR_ID --id TASK_ID
|
||
|
|
// node todo.mjs delete --list NAME_OR_ID --id TASK_ID
|
||
|
|
// node todo.mjs checklist --list NAME_OR_ID --id TASK_ID List checklist (subtasks)
|
||
|
|
// node todo.mjs checklist-add --list NAME_OR_ID --id TASK_ID --name "step"
|
||
|
|
// node todo.mjs checklist-check --list NAME_OR_ID --id TASK_ID --item ITEM_ID [--uncheck]
|
||
|
|
// node todo.mjs checklist-delete --list NAME_OR_ID --id TASK_ID --item ITEM_ID
|
||
|
|
//
|
||
|
|
// --fields accepts inline JSON, @path/to/file.json, or "-" (stdin) and is merged
|
||
|
|
// over the convenience flags, so you can express anything the Graph API supports.
|
||
|
|
|
||
|
|
import { readFileSync, existsSync } from "node:fs";
|
||
|
|
import { dirname, join } from "node:path";
|
||
|
|
import { fileURLToPath } from "node:url";
|
||
|
|
import {
|
||
|
|
MSTodoClient,
|
||
|
|
GraphError,
|
||
|
|
AuthRequiredError,
|
||
|
|
toDateTimeTimeZone,
|
||
|
|
} from "./lib/graph.mjs";
|
||
|
|
|
||
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
|
|
|
||
|
|
main().catch((err) => {
|
||
|
|
const payload = {
|
||
|
|
ok: false,
|
||
|
|
error: err.message,
|
||
|
|
...(err instanceof GraphError
|
||
|
|
? { status: err.status, code: err.code, requestId: err.requestId }
|
||
|
|
: {}),
|
||
|
|
...(err instanceof AuthRequiredError ? { needsLogin: true } : {}),
|
||
|
|
};
|
||
|
|
process.stderr.write(JSON.stringify(payload, null, 2) + "\n");
|
||
|
|
process.exit(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
loadDotEnv();
|
||
|
|
const [, , command, ...rest] = process.argv;
|
||
|
|
const flags = parseFlags(rest);
|
||
|
|
|
||
|
|
if (!command || command === "help" || flags.help) {
|
||
|
|
printHelp();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const client = new MSTodoClient({
|
||
|
|
clientId: env("TODO_CLIENT_ID", "AZURE_CLIENT_ID"),
|
||
|
|
tenantId: env("TODO_TENANT_ID", "AZURE_TENANT_ID") || "common",
|
||
|
|
tokenCachePath: env("TODO_TOKEN_CACHE") || join(__dirname, ".token-cache.json"),
|
||
|
|
redirectPort: env("TODO_REDIRECT_PORT") ? Number(env("TODO_REDIRECT_PORT")) : 0,
|
||
|
|
});
|
||
|
|
const tz = env("TODO_TIMEZONE") || "UTC";
|
||
|
|
|
||
|
|
const needList = () => {
|
||
|
|
if (!flags.list) throw new Error("Missing --list NAME_OR_ID.");
|
||
|
|
return flags.list;
|
||
|
|
};
|
||
|
|
const needId = () => {
|
||
|
|
if (!flags.id) throw new Error("Missing --id TASK_ID.");
|
||
|
|
return flags.id;
|
||
|
|
};
|
||
|
|
const needItem = () => {
|
||
|
|
if (!flags.item) throw new Error("Missing --item ITEM_ID.");
|
||
|
|
return flags.item;
|
||
|
|
};
|
||
|
|
|
||
|
|
let result;
|
||
|
|
switch (command) {
|
||
|
|
case "login":
|
||
|
|
result = await client.login((p) => {
|
||
|
|
process.stderr.write(
|
||
|
|
`\nOpening your browser to sign in…\nIf it didn't open, paste this URL:\n${p.authorizeUrl}\n\n` +
|
||
|
|
`Waiting for you to finish signing in (redirect to ${p.redirectUri})…\n\n`,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
result = { ok: true, signedIn: result };
|
||
|
|
break;
|
||
|
|
case "logout":
|
||
|
|
result = { ok: true, loggedOut: client.logout() };
|
||
|
|
break;
|
||
|
|
case "test":
|
||
|
|
result = await client.test();
|
||
|
|
break;
|
||
|
|
case "lists":
|
||
|
|
result = await client.listTaskLists();
|
||
|
|
break;
|
||
|
|
case "list-create":
|
||
|
|
result = await client.createTaskList(needName());
|
||
|
|
break;
|
||
|
|
case "list-update":
|
||
|
|
result = await client.updateTaskList(needList(), needName());
|
||
|
|
break;
|
||
|
|
case "list-delete":
|
||
|
|
await client.deleteTaskList(needList());
|
||
|
|
result = { ok: true, deletedList: needList() };
|
||
|
|
break;
|
||
|
|
case "tasks":
|
||
|
|
result = await client.listTasks(needList(), {
|
||
|
|
filter: flags.filter,
|
||
|
|
select: flags.select,
|
||
|
|
orderby: flags.orderby,
|
||
|
|
top: flags.top ? Number(flags.top) : undefined,
|
||
|
|
all: Boolean(flags.all),
|
||
|
|
});
|
||
|
|
break;
|
||
|
|
case "get":
|
||
|
|
result = await client.getTask(needList(), needId());
|
||
|
|
break;
|
||
|
|
case "create":
|
||
|
|
result = await client.createTask(needList(), buildTaskFields(flags, tz, true));
|
||
|
|
break;
|
||
|
|
case "update":
|
||
|
|
result = await client.updateTask(needList(), needId(), buildTaskFields(flags, tz, false));
|
||
|
|
break;
|
||
|
|
case "done":
|
||
|
|
result = await client.completeTask(needList(), needId());
|
||
|
|
break;
|
||
|
|
case "delete":
|
||
|
|
await client.deleteTask(needList(), needId());
|
||
|
|
result = { ok: true, deleted: needId() };
|
||
|
|
break;
|
||
|
|
case "checklist":
|
||
|
|
result = await client.listChecklistItems(needList(), needId());
|
||
|
|
break;
|
||
|
|
case "checklist-add":
|
||
|
|
result = await client.createChecklistItem(needList(), needId(), needName());
|
||
|
|
break;
|
||
|
|
case "checklist-check":
|
||
|
|
result = await client.updateChecklistItem(needList(), needId(), needItem(), {
|
||
|
|
isChecked: !flags.uncheck,
|
||
|
|
displayName: flags.name,
|
||
|
|
});
|
||
|
|
break;
|
||
|
|
case "checklist-delete":
|
||
|
|
await client.deleteChecklistItem(needList(), needId(), needItem());
|
||
|
|
result = { ok: true, deletedItem: needItem() };
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
throw new Error(`Unknown command "${command}". Run "node todo.mjs help".`);
|
||
|
|
}
|
||
|
|
|
||
|
|
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
||
|
|
|
||
|
|
function needName() {
|
||
|
|
if (!flags.name) throw new Error("Missing --name VALUE.");
|
||
|
|
return flags.name;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- helpers --------------------------------------------------------------
|
||
|
|
|
||
|
|
function env(...names) {
|
||
|
|
for (const n of names) if (process.env[n]) return process.env[n];
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseFlags(args) {
|
||
|
|
const flags = {};
|
||
|
|
for (let i = 0; i < args.length; i++) {
|
||
|
|
const a = args[i];
|
||
|
|
if (!a.startsWith("--")) continue;
|
||
|
|
const key = a.slice(2);
|
||
|
|
const next = args[i + 1];
|
||
|
|
if (next === undefined || next.startsWith("--")) {
|
||
|
|
flags[key] = true; // boolean flag (e.g. --all, --uncheck)
|
||
|
|
} else {
|
||
|
|
flags[key] = next;
|
||
|
|
i++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return flags;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build a todoTask body from convenience flags, then merge raw --fields JSON on
|
||
|
|
* top so power users can override anything. On create, title is required.
|
||
|
|
*/
|
||
|
|
function buildTaskFields(flags, tz, requireTitle) {
|
||
|
|
const fields = {};
|
||
|
|
if (flags.title) fields.title = flags.title;
|
||
|
|
if (flags.importance) fields.importance = flags.importance; // low|normal|high
|
||
|
|
if (flags.status) fields.status = flags.status; // notStarted|inProgress|completed|waitingOnOthers|deferred
|
||
|
|
if (flags.body) fields.body = { content: flags.body, contentType: "text" };
|
||
|
|
if (flags.due) fields.dueDateTime = toDateTimeTimeZone(flags.due, tz);
|
||
|
|
if (flags.start) fields.startDateTime = toDateTimeTimeZone(flags.start, tz);
|
||
|
|
if (flags.reminder) {
|
||
|
|
fields.reminderDateTime = toDateTimeTimeZone(flags.reminder, tz);
|
||
|
|
fields.isReminderOn = true;
|
||
|
|
}
|
||
|
|
if (flags.categories) {
|
||
|
|
fields.categories = String(flags.categories)
|
||
|
|
.split(",")
|
||
|
|
.map((s) => s.trim())
|
||
|
|
.filter(Boolean);
|
||
|
|
}
|
||
|
|
|
||
|
|
const raw = readFieldsFlag(flags);
|
||
|
|
Object.assign(fields, raw);
|
||
|
|
|
||
|
|
if (requireTitle && !fields.title) {
|
||
|
|
throw new Error("Missing --title (or a title in --fields) for create.");
|
||
|
|
}
|
||
|
|
return fields;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readFieldsFlag(flags) {
|
||
|
|
const raw = flags.fields;
|
||
|
|
if (raw === undefined || raw === true) return {};
|
||
|
|
let text = raw;
|
||
|
|
if (raw === "-") text = readFileSync(0, "utf8");
|
||
|
|
else if (raw.startsWith("@")) text = readFileSync(raw.slice(1), "utf8");
|
||
|
|
try {
|
||
|
|
return JSON.parse(text);
|
||
|
|
} catch (e) {
|
||
|
|
throw new Error(`--fields is not valid JSON: ${e.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Minimal .env loader (no dependency). Looks in CWD then this skill's dir.
|
||
|
|
function loadDotEnv() {
|
||
|
|
for (const dir of [process.cwd(), __dirname, join(__dirname, "..")]) {
|
||
|
|
const file = join(dir, ".env");
|
||
|
|
if (!existsSync(file)) continue;
|
||
|
|
for (const line of readFileSync(file, "utf8").split("\n")) {
|
||
|
|
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
|
||
|
|
if (!m) continue;
|
||
|
|
const key = m[1];
|
||
|
|
let val = m[2].trim();
|
||
|
|
if (
|
||
|
|
(val.startsWith('"') && val.endsWith('"')) ||
|
||
|
|
(val.startsWith("'") && val.endsWith("'"))
|
||
|
|
) {
|
||
|
|
val = val.slice(1, -1);
|
||
|
|
}
|
||
|
|
if (process.env[key] === undefined) process.env[key] = val;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function printHelp() {
|
||
|
|
process.stdout.write(
|
||
|
|
`ms-todo CLI — Microsoft To Do via Microsoft Graph (delegated / browser auth-code+PKCE)
|
||
|
|
|
||
|
|
Auth (one-time):
|
||
|
|
login Sign in (opens your browser)
|
||
|
|
logout Forget the cached sign-in
|
||
|
|
test Verify auth; show signed-in user
|
||
|
|
|
||
|
|
Task lists:
|
||
|
|
lists List all task lists
|
||
|
|
list-create --name "Groceries"
|
||
|
|
list-update --list NAME_OR_ID --name "New name"
|
||
|
|
list-delete --list NAME_OR_ID
|
||
|
|
|
||
|
|
Tasks:
|
||
|
|
tasks --list NAME_OR_ID [filters] Query tasks
|
||
|
|
get --list NAME_OR_ID --id TASK_ID Get one task
|
||
|
|
create --list NAME_OR_ID --title "..." [opts] Create a task
|
||
|
|
update --list NAME_OR_ID --id TASK_ID [opts] Update a task
|
||
|
|
done --list NAME_OR_ID --id TASK_ID Mark a task completed
|
||
|
|
delete --list NAME_OR_ID --id TASK_ID Delete a task
|
||
|
|
|
||
|
|
task opts: --title --body --importance low|normal|high --status notStarted|inProgress|completed
|
||
|
|
--due 2026-07-01 --start ... --reminder ... --categories "a,b"
|
||
|
|
tasks filters: --filter "status ne 'completed'" --select title,status,dueDateTime
|
||
|
|
--orderby "dueDateTime/dateTime asc" --top 50 --all
|
||
|
|
|
||
|
|
Checklist items (subtasks):
|
||
|
|
checklist --list NAME_OR_ID --id TASK_ID
|
||
|
|
checklist-add --list NAME_OR_ID --id TASK_ID --name "step"
|
||
|
|
checklist-check --list NAME_OR_ID --id TASK_ID --item ITEM_ID [--uncheck]
|
||
|
|
checklist-delete --list NAME_OR_ID --id TASK_ID --item ITEM_ID
|
||
|
|
|
||
|
|
--fields accepts inline JSON, @file.json, or - (stdin); it merges over the flags.
|
||
|
|
Config (env / .env): TODO_CLIENT_ID (required), TODO_TENANT_ID (default "common"),
|
||
|
|
TODO_TOKEN_CACHE, TODO_TIMEZONE (default UTC).
|
||
|
|
`,
|
||
|
|
);
|
||
|
|
}
|