2026-06-16 11:12:50 -06:00
|
|
|
#!/usr/bin/env node
|
|
|
|
|
// CLI wrapper around SharePointListsClient. Prints JSON to stdout; errors to
|
|
|
|
|
// stderr with a non-zero exit so callers can detect failure reliably.
|
|
|
|
|
//
|
|
|
|
|
// Credentials are read from the environment (or a .env file in the CWD or this
|
|
|
|
|
// skill's directory). Required:
|
|
|
|
|
// SP_TENANT_ID (or AZURE_TENANT_ID)
|
|
|
|
|
// SP_CLIENT_ID (or AZURE_CLIENT_ID)
|
|
|
|
|
// SP_CLIENT_SECRET (or AZURE_CLIENT_SECRET)
|
|
|
|
|
// Optional default so you can omit --site on every call:
|
|
|
|
|
// SP_SITE_URL e.g. https://contoso.sharepoint.com/sites/Marketing
|
|
|
|
|
//
|
|
|
|
|
// Usage:
|
|
|
|
|
// node sp.mjs test [--site URL]
|
|
|
|
|
// node sp.mjs lists --site URL
|
|
|
|
|
// node sp.mjs columns --site URL --list NAME_OR_ID
|
|
|
|
|
// node sp.mjs items --site URL --list NAME_OR_ID [--filter ODATA] [--select F1,F2] [--orderby "fields/Created desc"] [--top N] [--all]
|
|
|
|
|
// node sp.mjs get --site URL --list NAME_OR_ID --id ITEM_ID
|
|
|
|
|
// node sp.mjs create --site URL --list NAME_OR_ID --fields '{"Title":"Hi"}'
|
|
|
|
|
// node sp.mjs update --site URL --list NAME_OR_ID --id ITEM_ID --fields '{"Status":"Done"}'
|
|
|
|
|
// node sp.mjs delete --site URL --list NAME_OR_ID --id ITEM_ID
|
|
|
|
|
//
|
|
|
|
|
// --fields accepts inline JSON, @path/to/file.json, or "-" to read JSON from stdin.
|
|
|
|
|
|
|
|
|
|
import { readFileSync, existsSync } from "node:fs";
|
|
|
|
|
import { dirname, join } from "node:path";
|
|
|
|
|
import { fileURLToPath } from "node:url";
|
2026-07-08 15:13:48 -06:00
|
|
|
import { SharePointListsClient, GraphError, secretExpiryStatus } from "./lib/graph.mjs";
|
2026-06-16 11:12:50 -06:00
|
|
|
|
|
|
|
|
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 }
|
|
|
|
|
: {}),
|
|
|
|
|
};
|
|
|
|
|
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 SharePointListsClient({
|
|
|
|
|
tenantId: env("SP_TENANT_ID", "AZURE_TENANT_ID"),
|
|
|
|
|
clientId: env("SP_CLIENT_ID", "AZURE_CLIENT_ID"),
|
|
|
|
|
clientSecret: env("SP_CLIENT_SECRET", "AZURE_CLIENT_SECRET"),
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-08 15:13:48 -06:00
|
|
|
// Nag (on stderr, so stdout stays clean JSON) when the client secret is
|
|
|
|
|
// close to its self-reported expiry — see secretExpiryStatus in graph.mjs.
|
|
|
|
|
const expiry = secretExpiryStatus(process.env.SP_SECRET_EXPIRES);
|
|
|
|
|
if (expiry?.message) process.stderr.write(`⚠ ${expiry.message}\n`);
|
|
|
|
|
|
2026-06-16 11:12:50 -06:00
|
|
|
const site = flags.site || process.env.SP_SITE_URL;
|
|
|
|
|
const needSite = () => {
|
|
|
|
|
if (!site) throw new Error("Missing --site URL (or set SP_SITE_URL).");
|
|
|
|
|
return client.resolveSiteId(site);
|
|
|
|
|
};
|
|
|
|
|
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 ITEM_ID.");
|
|
|
|
|
return flags.id;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let result;
|
|
|
|
|
switch (command) {
|
|
|
|
|
case "test":
|
|
|
|
|
result = await client.test(site);
|
2026-07-08 15:13:48 -06:00
|
|
|
if (expiry) result.secretExpiry = expiry;
|
2026-06-16 11:12:50 -06:00
|
|
|
break;
|
|
|
|
|
case "lists":
|
|
|
|
|
result = await client.listLists(await needSite());
|
|
|
|
|
break;
|
|
|
|
|
case "columns":
|
|
|
|
|
result = await client.getColumns(await needSite(), needList());
|
|
|
|
|
break;
|
|
|
|
|
case "items":
|
|
|
|
|
result = await client.listItems(await needSite(), 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.getItem(await needSite(), needList(), needId());
|
|
|
|
|
break;
|
|
|
|
|
case "create":
|
|
|
|
|
result = await client.createItem(await needSite(), needList(), readFields(flags));
|
|
|
|
|
break;
|
|
|
|
|
case "update":
|
|
|
|
|
result = await client.updateItem(await needSite(), needList(), needId(), readFields(flags));
|
|
|
|
|
break;
|
|
|
|
|
case "delete":
|
|
|
|
|
result = await client.deleteItem(await needSite(), needList(), needId());
|
|
|
|
|
result = { ok: true, deleted: needId() };
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
throw new Error(`Unknown command "${command}". Run "node sp.mjs help".`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- 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)
|
|
|
|
|
} else {
|
|
|
|
|
flags[key] = next;
|
|
|
|
|
i++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return flags;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readFields(flags) {
|
|
|
|
|
const raw = flags.fields;
|
|
|
|
|
if (raw === undefined || raw === true) {
|
|
|
|
|
throw new Error('Missing --fields \'{"Col":"value"}\' (or @file.json, or - for stdin).');
|
|
|
|
|
}
|
|
|
|
|
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(
|
|
|
|
|
`sharepoint-lists CLI — Microsoft Graph, app-only auth
|
|
|
|
|
|
|
|
|
|
Commands:
|
|
|
|
|
test [--site URL] Verify auth (+ list lists if --site)
|
|
|
|
|
lists --site URL List all lists in a site
|
|
|
|
|
columns --site URL --list NAME_OR_ID Show column internal names + types
|
|
|
|
|
items --site URL --list NAME_OR_ID [filters] Query list items
|
|
|
|
|
get --site URL --list NAME_OR_ID --id ID Get one item
|
|
|
|
|
create --site URL --list NAME_OR_ID --fields J Create an item
|
|
|
|
|
update --site URL --list NAME_OR_ID --id ID --fields J Update an item
|
|
|
|
|
delete --site URL --list NAME_OR_ID --id ID Delete an item
|
|
|
|
|
|
|
|
|
|
items filters:
|
|
|
|
|
--filter "fields/Status eq 'Open'" --select Title,Status
|
|
|
|
|
--orderby "fields/Created desc" --top 50 --all
|
|
|
|
|
|
|
|
|
|
--fields accepts inline JSON, @file.json, or - (stdin).
|
|
|
|
|
Credentials come from env / .env: SP_TENANT_ID, SP_CLIENT_ID, SP_CLIENT_SECRET.
|
|
|
|
|
Optional: SP_SITE_URL to default --site.
|
|
|
|
|
`,
|
|
|
|
|
);
|
|
|
|
|
}
|