Files
ang3l12 fd957177b9 Initial import: SharePoint Lists skill
Claude Code skill for SharePoint Lists CRUD via Microsoft Graph (app-only auth): reusable graph.mjs client, sp.mjs CLI, and setup + API reference docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:12:50 -06:00

342 lines
12 KiB
JavaScript

// SharePoint Lists core client — Microsoft Graph, app-only (client credentials).
//
// Zero dependencies. Uses Node's global fetch (Node 18+). This module is the
// single source of truth for talking to SharePoint Lists; the CLI (sp.mjs) is a
// thin wrapper around it, and a future MCP server can import this same class.
//
// Auth model: app-only via OAuth2 client credentials. The app registration must
// hold an *application* permission such as Sites.Selected (preferred) or
// Sites.ReadWrite.All, with admin consent granted. See references/setup.md.
const LOGIN_HOST = "https://login.microsoftonline.com";
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Error carrying the Graph status code + structured error body, when present. */
export class GraphError extends Error {
constructor(message, { status, code, requestId, body } = {}) {
super(message);
this.name = "GraphError";
this.status = status;
this.code = code;
this.requestId = requestId;
this.body = body;
}
}
export class SharePointListsClient {
/**
* @param {object} cfg
* @param {string} cfg.tenantId Directory (tenant) ID or domain.
* @param {string} cfg.clientId Application (client) ID.
* @param {string} cfg.clientSecret Client secret value.
* @param {number} [cfg.maxRetries=3] Retries for 429/503 (honors Retry-After).
*/
constructor({ tenantId, clientId, clientSecret, maxRetries = 3 } = {}) {
if (!tenantId || !clientId || !clientSecret) {
throw new Error(
"Missing credentials: tenantId, clientId, and clientSecret are all required.",
);
}
this.tenantId = tenantId;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.maxRetries = maxRetries;
this._token = null; // { accessToken, expiresAt }
this._siteCache = new Map(); // siteUrl -> siteId
this._listCache = new Map(); // `${siteId}::${nameOrId}` -> list object
}
// ---- Auth ---------------------------------------------------------------
async getToken() {
const now = Date.now();
if (this._token && this._token.expiresAt - 60_000 > now) {
return this._token.accessToken;
}
const url = `${LOGIN_HOST}/${encodeURIComponent(this.tenantId)}/oauth2/v2.0/token`;
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: this.clientId,
client_secret: this.clientSecret,
scope: "https://graph.microsoft.com/.default",
});
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
throw new GraphError(
`Token request failed (${res.status}): ${json.error_description || json.error || res.statusText}`,
{ status: res.status, code: json.error, body: json },
);
}
this._token = {
accessToken: json.access_token,
expiresAt: now + (json.expires_in ?? 3600) * 1000,
};
return this._token.accessToken;
}
// ---- Low-level request --------------------------------------------------
/**
* Make a Graph request. `path` is relative to the v1.0 root (e.g. "/sites/...").
* @param {string} method
* @param {string} path
* @param {object} [opts]
* @param {object} [opts.query] Query params; keys starting with "$" are OData.
* @param {object} [opts.body] JSON body (will be stringified).
* @param {object} [opts.headers] Extra headers (e.g. Prefer).
*/
async graph(method, path, { query, body, headers } = {}) {
const url = new URL(path.startsWith("http") ? path : `${GRAPH_ROOT}${path}`);
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, v);
}
}
for (let attempt = 0; ; attempt++) {
const token = await this.getToken();
const res = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
...(body ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
if ((res.status === 429 || res.status === 503) && attempt < this.maxRetries) {
const retryAfter = Number(res.headers.get("Retry-After")) || 2 ** attempt;
await sleep(retryAfter * 1000);
continue;
}
if (res.status === 204) return null; // No Content (e.g. DELETE)
const json = await res.json().catch(() => null);
if (!res.ok) {
const err = json?.error || {};
throw new GraphError(
`Graph ${method} ${url.pathname} failed (${res.status}): ${err.message || res.statusText}`,
{
status: res.status,
code: err.code,
requestId: res.headers.get("request-id"),
body: json,
},
);
}
return json;
}
}
// ---- Site & list resolution --------------------------------------------
/**
* Resolve a site URL (or host:path, or raw site id) to a Graph site id.
* Accepts:
* - "https://contoso.sharepoint.com/sites/Marketing"
* - "contoso.sharepoint.com:/sites/Marketing"
* - "contoso.sharepoint.com,<guid>,<guid>" (already a site id) -> returned as-is
*/
async resolveSiteId(site) {
if (!site) throw new Error("A site URL or id is required.");
// Already a composite site id (host,guid,guid)?
if (site.includes(",")) return site;
if (this._siteCache.has(site)) return this._siteCache.get(site);
let host, relPath;
if (site.startsWith("http://") || site.startsWith("https://")) {
const u = new URL(site);
host = u.host;
relPath = u.pathname.replace(/\/$/, "");
} else if (site.includes(":")) {
[host, relPath] = site.split(":");
relPath = (relPath || "").replace(/\/$/, "");
} else {
host = site; // bare hostname -> root site
relPath = "";
}
const path = relPath
? `/sites/${host}:${encodeURI(relPath)}`
: `/sites/${host}`;
const data = await this.graph("GET", path, { query: { $select: "id,displayName,webUrl" } });
this._siteCache.set(site, data.id);
return data.id;
}
/** List all lists in a site. */
async listLists(siteId, { select = "id,name,displayName,webUrl,list" } = {}) {
const data = await this.graph("GET", `/sites/${siteId}/lists`, {
query: { $select: select },
});
return data.value;
}
/**
* Resolve a list identifier (GUID, internal name, or display name) to its id.
* Caches per (siteId, identifier).
*/
async resolveListId(siteId, listIdOrName) {
if (!listIdOrName) throw new Error("A list id or name is required.");
if (GUID_RE.test(listIdOrName)) return listIdOrName;
const key = `${siteId}::${listIdOrName}`;
if (this._listCache.has(key)) return this._listCache.get(key).id;
const lists = await this.listLists(siteId);
const needle = listIdOrName.toLowerCase();
const match = lists.find(
(l) =>
l.name?.toLowerCase() === needle ||
l.displayName?.toLowerCase() === needle,
);
if (!match) {
const names = lists.map((l) => l.displayName || l.name).join(", ");
throw new Error(
`No list named "${listIdOrName}" in this site. Available: ${names || "(none)"}`,
);
}
this._listCache.set(key, match);
return match.id;
}
/** Get a single list's metadata. */
async getList(siteId, listIdOrName) {
const listId = await this.resolveListId(siteId, listIdOrName);
return this.graph("GET", `/sites/${siteId}/lists/${listId}`);
}
/** Get column definitions (internal names, types) for a list. */
async getColumns(siteId, listIdOrName) {
const listId = await this.resolveListId(siteId, listIdOrName);
const data = await this.graph("GET", `/sites/${siteId}/lists/${listId}/columns`);
return data.value;
}
// ---- Items: read --------------------------------------------------------
/**
* List items with their field values.
* @param {object} [opts]
* @param {string} [opts.filter] OData $filter, e.g. "fields/Status eq 'Open'".
* @param {string} [opts.select] Fields to return, e.g. "Title,Status".
* @param {string} [opts.orderby] e.g. "fields/Created desc".
* @param {number} [opts.top] Page size / max when not fetching all.
* @param {boolean}[opts.all] Follow @odata.nextLink to fetch every page.
*/
async listItems(siteId, listIdOrName, opts = {}) {
const listId = await this.resolveListId(siteId, listIdOrName);
const { filter, select, orderby, top, all = false } = opts;
// Filtering/sorting on non-indexed columns requires this Prefer header.
const headers = filter || orderby
? { Prefer: "HonorNonIndexedQueriesWarningMayFailRandomly" }
: undefined;
const expand = select
? `fields($select=${select})`
: "fields";
const query = {
expand,
$filter: filter,
$orderby: orderby,
$top: top,
};
let data = await this.graph("GET", `/sites/${siteId}/lists/${listId}/items`, {
query,
headers,
});
const items = data.value.map(simplifyItem);
if (!all) return items;
while (data["@odata.nextLink"]) {
data = await this.graph("GET", data["@odata.nextLink"], { headers });
items.push(...data.value.map(simplifyItem));
}
return items;
}
/** Get a single item by id, including its fields. */
async getItem(siteId, listIdOrName, itemId) {
const listId = await this.resolveListId(siteId, listIdOrName);
const data = await this.graph(
"GET",
`/sites/${siteId}/lists/${listId}/items/${itemId}`,
{ query: { expand: "fields" } },
);
return simplifyItem(data);
}
// ---- Items: write -------------------------------------------------------
/** Create an item. `fields` is a map of internal column name -> value. */
async createItem(siteId, listIdOrName, fields) {
const listId = await this.resolveListId(siteId, listIdOrName);
const data = await this.graph(
"POST",
`/sites/${siteId}/lists/${listId}/items`,
{ body: { fields } },
);
return simplifyItem(data);
}
/** Update an item's fields (partial; only provided fields change). */
async updateItem(siteId, listIdOrName, itemId, fields) {
const listId = await this.resolveListId(siteId, listIdOrName);
const data = await this.graph(
"PATCH",
`/sites/${siteId}/lists/${listId}/items/${itemId}/fields`,
{ body: fields },
);
return { id: String(itemId), fields: data };
}
/** Delete an item by id. Returns true on success. */
async deleteItem(siteId, listIdOrName, itemId) {
const listId = await this.resolveListId(siteId, listIdOrName);
await this.graph(
"DELETE",
`/sites/${siteId}/lists/${listId}/items/${itemId}`,
);
return true;
}
// ---- Convenience --------------------------------------------------------
/** Verify credentials + connectivity. Returns the resolved site (if given). */
async test(site) {
await this.getToken();
if (!site) return { ok: true, token: "acquired" };
const siteId = await this.resolveSiteId(site);
const lists = await this.listLists(siteId);
return {
ok: true,
siteId,
listCount: lists.length,
lists: lists.map((l) => l.displayName || l.name),
};
}
}
/** Flatten the Graph item shape into { id, fields, webUrl, ...meta }. */
function simplifyItem(item) {
if (!item) return item;
const { id, webUrl, createdDateTime, lastModifiedDateTime, fields } = item;
return { id, webUrl, createdDateTime, lastModifiedDateTime, fields: fields ?? {} };
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
export default SharePointListsClient;