#!/usr/bin/env node /** * Simulate a teacher's work against the LOCAL instance: create, change and * delete the things teachers actually create, so the MCP server can be exercised * against content the real account has never contained. * * This is the only thing in this repository that writes to a Schulcloud, and the * guard below is what keeps it that way. The MCP server itself stays read-only; * nothing here runs through it. * * node scripts/simulate-teacher.mjs create # build the fixture, print ids * node scripts/simulate-teacher.mjs update # rename/edit everything it made * node scripts/simulate-teacher.mjs delete # remove it again * node scripts/simulate-teacher.mjs files # (re)build only the file-manager part * * State lives in .simulate-teacher.json so the phases can be run one at a time * with MCP checks in between. */ import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const STATE = join(ROOT, '.simulate-teacher.json'); // The server's own port, not the nginx origin: `/api/v1` is deliberately not // routed through the ingress (the live deployment does the same), and the // legacy services are the only way to create tasks and topics. const API = process.env.LOCAL_SC_API ?? 'http://127.0.0.1:3030'; const FILES = process.env.LOCAL_SC_FILES ?? 'http://127.0.0.1:4444'; for (const [name, url] of [['LOCAL_SC_API', API], ['LOCAL_SC_FILES', FILES]]) { const { hostname } = new URL(url); if (hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '::1') { console.error(`refusing to run: ${name}=${url} is not a localhost address.`); process.exit(2); } } const TEACHER = process.env.SIM_TEACHER ?? 'klara.fall@schul-cloud.org'; const PASSWORD = process.env.SIM_PASSWORD ?? 'Schulcloud1!'; /** Everything is created here, because the demo student is a member. */ const COURSE = process.env.SIM_COURSE ?? '59a3c657a2049554a93fec3a'; // Biologie 9b const STUDENT_EMAIL = 'demo-schueler@schul-cloud.org'; let jwt = ''; let me; async function req(base, method, path, body, { raw = false } = {}) { const headers = { Authorization: `Bearer ${jwt}` }; let payload; if (body instanceof FormData) { payload = body; } else if (body !== undefined) { headers['Content-Type'] = 'application/json'; payload = JSON.stringify(body); } const res = await fetch(`${base}${path}`, { method, headers, body: payload }); const text = await res.text(); if (!res.ok) { throw new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 400)}`); } if (raw) return text; return text ? JSON.parse(text) : undefined; } const v3 = (method, path, body, opts) => req(API, method, `/api/v3${path}`, body, opts); const v1 = (method, path, body, opts) => req(API, method, `/api/v1${path}`, body, opts); const files = (method, path, body, opts) => req(FILES, method, `/api/v3/file${path}`, body, opts); const log = (...a) => console.log(' ', ...a); const step = (s) => console.log(`\n== ${s} ==`); function loadState() { return existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : {}; } function saveState(s) { writeFileSync(STATE, JSON.stringify(s, null, '\t') + '\n'); } async function login() { const res = await fetch(`${API}/api/v3/authentication/local`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: TEACHER, password: PASSWORD }), }); if (!res.ok) throw new Error(`login failed: ${res.status} ${await res.text()}`); jwt = (await res.json()).accessToken; me = await v3('GET', '/me'); log(`signed in as ${me.user.firstName} ${me.user.lastName} (${me.roles.map((r) => r.name).join(', ')})`); } /** The demo student, so created content is visible to the account under test. */ async function studentId() { const found = await v1('GET', `/users?email=${encodeURIComponent(STUDENT_EMAIL)}`); const user = found.data?.[0] ?? found[0]; if (!user) throw new Error(`could not find ${STUDENT_EMAIL}`); return user._id; } // ---------------------------------------------------------------- create --- async function create() { const s = loadState(); // Saved after every step, not at the end: a failure halfway through used to // leave created objects with no record of their ids, and so no way to delete // them again. const keep = (k, v) => { s[k] = v; saveState(s); return v; }; const stamp = new Date().toISOString().slice(0, 16).replace('T', ' '); const student = await studentId(); step('course'); const course = await v3('POST', '/courses', { name: `MCP-Test Kurs (${stamp})` }); keep('courseId', course.courseId ?? course.id ?? course._id); // v3 creates the course but takes no members; the legacy service does. await v1('PATCH', `/courses/${s.courseId}`, { userIds: [student], teacherIds: [me.user.id] }); log(`course ${s.courseId} with the demo student enrolled`); step('room'); const room = await v3('POST', '/rooms', { name: `MCP-Test Raum (${stamp})`, color: 'blue', features: [], }); keep('roomId', room.id); log(`room ${s.roomId}`); step('room contents'); // Rooms are the newer collaboration space, separate from courses: a room has // its own members and its own boards, and appears under "Räume" in the UI // while courses appear under "Kurse" at a /rooms/... url. await v3('PATCH', `/rooms/${s.roomId}/members/add`, { userIds: [student] }); log('demo student added as a room member'); const roomBoard = await v3('POST', '/boards', { title: `MCP-Test Raum-Board (${stamp})`, parentId: s.roomId, parentType: 'room', layout: 'columns', }); keep('roomBoardId', roomBoard.id); const roomColumn = await v3('POST', `/boards/${s.roomBoardId}/columns`); await v3('PATCH', `/columns/${roomColumn.id}/title`, { title: 'Projektarbeit' }); const roomCard = await v3('POST', `/columns/${roomColumn.id}/cards`); await v3('PATCH', `/cards/${roomCard.id}/title`, { title: 'Aufgabenverteilung' }); const roomText = await v3('POST', `/cards/${roomCard.id}/elements`, { type: 'richText' }); await v3('PATCH', `/elements/${roomText.id}/content`, { data: { content: { text: '

Raum-Inhalt, nicht Kurs-Inhalt. Suchbegriff: Projektsteuerung.

', inputFormat: 'richTextCk5', }, type: 'richText', }, }); // A file in a room board, so the mirror path (room name, not course name) and // the CLI manifest are exercised for rooms too. const roomFileEl = await v3('POST', `/cards/${roomCard.id}/elements`, { type: 'file' }); keep('roomFileId', await upload(roomFileEl.id, 'projektplan.txt', 'text/plain', 'Projektplan\n\nMeilenstein 1: Anforderungen. Stichwort: Projektsteuerung.\n')); await v3('PATCH', `/boards/${s.roomBoardId}/visibility`, { isVisible: true }); log(`room board ${s.roomBoardId} published, with one card`); // A second room the student is NOT in, so "only rooms I belong to" is testable. const other = await v3('POST', '/rooms', { name: `MCP-Test Raum ohne Zugriff (${stamp})`, color: 'red', features: [], }); keep('roomWithoutStudentId', other.id); log(`second room ${other.id} left without the student on purpose`); step('team'); // Teams cannot be created through the API: the legacy service registers // ['find','get','update','patch','remove'] and no 'create' // (schulcloud-server src/services/teams/index.js), and v3 has no team route // beyond news and create-room. POST /teams answers 405. So the simulation // adopts a seeded team and edits that instead — which is all the MCP server // needs, since it reads teams only through api_get. const teams = await v1('GET', '/teams'); const existing = (teams.data ?? [])[0]; if (existing) { keep('teamId', existing._id); log(`adopted seeded team ${existing._id} (${existing.name}) — creation is not exposed`); } else { log('no seeded team to adopt; skipping'); } step('topic (legacy lesson) in Biologie 9b'); const lesson = await v1('POST', '/lessons', { name: `MCP-Test Thema (${stamp})`, courseId: COURSE, hidden: false, contents: [ { title: 'Einführung', hidden: false, component: 'text', content: { text: '

Dieses Thema prüft, was der MCP-Server aus einem Thema liest.

' }, }, ], }); keep('lessonId', lesson._id); log(`lesson ${s.lessonId}`); step('task (homework) in Biologie 9b'); const task = await v1('POST', '/homework', { name: `MCP-Test Aufgabe (${stamp})`, description: '

Beschreibe in drei Sätzen, was ein Neuron tut.

', courseId: COURSE, availableDate: new Date(Date.now() - 86400_000).toISOString(), dueDate: new Date(Date.now() + 7 * 86400_000).toISOString(), private: false, teacherId: me.user.id, schoolId: me.school.id, }); keep('taskId', task._id); log(`task ${s.taskId}`); step('column board in Biologie 9b'); const board = await v3('POST', '/boards', { title: `MCP-Test Board (${stamp})`, parentId: COURSE, parentType: 'course', layout: 'columns', }); keep('boardId', board.id); const column = await v3('POST', `/boards/${s.boardId}/columns`); keep('columnId', column.id); await v3('PATCH', `/columns/${s.columnId}/title`, { title: 'Material' }); const card = await v3('POST', `/columns/${s.columnId}/cards`); keep('cardId', card.id); await v3('PATCH', `/cards/${s.cardId}/title`, { title: 'Das Nervensystem' }); log(`board ${s.boardId} / column ${s.columnId} / card ${s.cardId}`); step('card elements'); const rich = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'richText' }); keep('richTextId', rich.id); await v3('PATCH', `/elements/${s.richTextId}/content`, { data: { content: { text: '

Ein Neuron leitet Reize weiter. Suchbegriff: Synapsenspalt.

', inputFormat: 'richTextCk5', }, type: 'richText', }, }); log(`richText ${s.richTextId}`); const link = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'link' }); keep('linkId', link.id); await v3('PATCH', `/elements/${s.linkId}/content`, { data: { content: { url: 'https://www.dbildungscloud.de/', title: 'dBildungscloud', description: '', imageUrl: '', originalImageUrl: '' }, type: 'link', }, }); log(`link ${s.linkId}`); const pad = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'collaborativeTextEditor' }); keep('padElementId', pad.id); log(`collaborativeTextEditor (Etherpad) ${s.padElementId}`); const folder = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'fileFolder' }); keep('folderId', folder.id); await v3('PATCH', `/elements/${s.folderId}/content`, { data: { content: { title: 'Arbeitsblätter' }, type: 'fileFolder' }, }); log(`fileFolder (directory) ${s.folderId}`); step('files'); const fileEl = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'file' }); keep('fileElementId', fileEl.id); keep('fileId', await upload(s.fileElementId, 'nervensystem-notiz.txt', 'text/plain', 'Das Nervensystem\n\nReizleitung erfolgt ueber Synapsen. Stichwort: Synapsenspalt.\n')); log(`file element ${s.fileElementId} holding file ${s.fileId}`); keep('folderFileId', await upload(s.folderId, 'arbeitsblatt-1.txt', 'text/plain', 'Arbeitsblatt 1\n\nAufgabe: Beschrifte die Teile eines Neurons.\n')); log(`file ${s.folderFileId} inside the directory`); step('publishing'); // A board is created as a draft. Students get 403 on it while the course page // still lists its title, so the fixture needs both states to be useful. await v3('PATCH', `/boards/${s.boardId}/visibility`, { isVisible: true }); log('main board published'); const draft = await v3('POST', '/boards', { title: `MCP-Test Entwurf, unveröffentlicht (${stamp})`, parentId: COURSE, parentType: 'course', layout: 'columns', }); keep('draftBoardId', draft.id); log(`second board left unpublished on purpose: ${draft.id}`); saveState(s); await fileManager(); console.log(`\nstate written to ${STATE}`); summary(loadState()); } /** files-storage attaches bytes to a board node (element) id, not to the card. */ async function upload(parentId, name, type, body) { const form = new FormData(); form.append('file', new Blob([body], { type }), name); const record = await files('POST', `/upload/school/${me.school.id}/boardnodes/${parentId}`, form); return record.id; } // ---------------------------------------------------------- file manager --- // // The file manager ("Dateien": Persönliche, Kurs-, Team- and Geteilte Dateien) // is the legacy file system, a different store from files-storage above, with a // real folder tree. Many teachers use nothing else, so the MCP server's fs_* // tools need content there. Its services are only reachable on the server's own // port, like the other /api/v1 writes in this script. const STUDENT_PASSWORD = process.env.SIM_STUDENT_PASSWORD ?? 'schulcloud'; const TEAM_MEMBER_ROLE = '5bb5c190fb457b1c3c0c7e0f'; // "teammember" in the seed /** Runs `fn` signed in as another account, then restores the teacher. */ async function asUser(email, password, fn) { const saved = { jwt, me }; const res = await fetch(`${API}/api/v3/authentication/local`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: email, password }), }); if (!res.ok) throw new Error(`login as ${email} failed: ${res.status}`); jwt = (await res.json()).accessToken; me = await v3('GET', '/me'); try { return await fn(); } finally { ({ jwt, me } = saved); } } async function legacyDir(name, owner, parent) { return (await v1('POST', '/fileStorage/directories', { name, owner, parent }))._id; } /** * The browser's own upload sequence: a signed PUT url, the bytes, then the * record. The url comes from the server, so it is held to the same localhost * rule as everything else this script writes to. */ async function legacyUpload({ owner, parent, name, type, body }) { const bytes = Buffer.from(body); const signed = await v1('POST', '/fileStorage/signedUrl', { parent, filename: name, fileType: type }); const { hostname } = new URL(signed.url); if (hostname !== '127.0.0.1' && hostname !== 'localhost') { throw new Error(`refusing to upload to ${hostname}: not a localhost address (see minio-loopback in docker-compose.yml)`); } const put = await fetch(signed.url, { method: 'PUT', headers: signed.header, body: bytes }); if (!put.ok) throw new Error(`PUT ${name} to storage -> ${put.status} ${(await put.text()).slice(0, 200)}`); const record = await v1('POST', '/fileStorage', { name, owner, parent, type, size: bytes.length, storageFileName: signed.header['x-amz-meta-flat-name'], }); return record._id; } async function fileManager() { const s = loadState(); if (!s.courseId) throw new Error('run `create` first: the file-manager fixture lives in its course'); const keep = (key, value) => { s[key] = value; saveState(s); }; const student = await studentId(); step('file manager: Kurs-Dateien'); keep('fmCourseRootFileId', await legacyUpload({ owner: s.courseId, name: 'Kursplan.txt', type: 'text/plain', body: 'Kursplan Biologie\n\nThemen: Zelle, Gewebe, Organe. Stichwort: Photosynthese-Lichtreaktion.\n', })); keep('fmCourseDirId', await legacyDir('Arbeitsblätter', s.courseId)); keep('fmCourseFileId', await legacyUpload({ owner: s.courseId, parent: s.fmCourseDirId, name: 'Blatt 1 - Zellorganellen.txt', type: 'text/plain', body: 'Blatt 1: Zellorganellen\n\nBeschrifte die Mitochondrienmembran und das endoplasmatische Retikulum.\n', })); keep('fmCourseSubDirId', await legacyDir('Woche 1', s.courseId, s.fmCourseDirId)); keep('fmCourseDeepFileId', await legacyUpload({ owner: s.courseId, parent: s.fmCourseSubDirId, name: 'Blatt 2 - Gewebe.txt', type: 'text/plain', body: 'Blatt 2: Gewebe\n\nVergleiche Epithelgewebe und Bindegewebe.\n', })); log(`course root file, folder "Arbeitsblätter" with a file, and "Woche 1" nested inside it`); if (s.teamId) { step('file manager: Team-Dateien'); // Teams cannot be created (see the README), and the adopted one does not // include the demo student, whose view is the one under test. const team = await v1('GET', `/teams/${s.teamId}`); if (!team.userIds.some((entry) => String(entry.userId?._id ?? entry.userId) === student)) { const userIds = team.userIds.map((entry) => ({ userId: String(entry.userId?._id ?? entry.userId), role: String(entry.role?._id ?? entry.role), schoolId: String(entry.schoolId?._id ?? entry.schoolId), })); userIds.push({ userId: student, role: TEAM_MEMBER_ROLE, schoolId: me.school.id }); await v1('PATCH', `/teams/${s.teamId}`, { userIds }); keep('fmStudentAddedToTeam', true); log('demo student added to the team'); } keep('fmTeamDirId', await legacyDir('Projekt', s.teamId)); keep('fmTeamFileId', await legacyUpload({ owner: s.teamId, parent: s.fmTeamDirId, name: 'Projektplan.txt', type: 'text/plain', body: 'Projektplan\n\nMeilenstein Chlorophyll bis Freitag.\n', })); log('team folder "Projekt" with a file'); } step('file manager: Persönliche Dateien (the student\'s own)'); // No `owner` for personal files, exactly as the upload page sends none: the // server decides the owner model as "a course, or else a team", so passing a // user id records the folder as a team's, and every later permission check on // it then dereferences a team that does not exist. await asUser(STUDENT_EMAIL, STUDENT_PASSWORD, async () => { keep('fmStudentDirId', await legacyDir('Notizen')); keep('fmStudentFileId', await legacyUpload({ parent: s.fmStudentDirId, name: 'Lernzettel.txt', type: 'text/plain', body: 'Lernzettel\n\nRibosomenfabrik: Proteinbiosynthese am rauen ER.\n', })); }); log('student folder "Notizen" with a file'); step('file manager: Geteilte Dateien'); keep('fmSharedFileId', await legacyUpload({ name: 'Geteilt vom Lehrer.txt', type: 'text/plain', body: 'Zusatzmaterial\n\nDie Zellkernhuelle trennt Kernplasma und Zytoplasma.\n', })); // What accepting a share link does in the legacy client: a read-only user // permission. Not the permission service, which writes `refOwnerModel` where // the "shared with me" query reads `refPermModel`, so its shares never show. const shared = await v1('GET', `/files/${s.fmSharedFileId}`); await v1('PATCH', `/files/${s.fmSharedFileId}`, { permissions: [ ...shared.permissions, { refId: student, refPermModel: 'user', read: true, write: false, delete: false, create: false }, ], }); log('teacher file shared read-only with the student'); } // ---------------------------------------------------------------- update --- async function update() { const s = loadState(); if (!s.boardId) throw new Error('nothing to update — run `create` first'); const stamp = new Date().toISOString().slice(11, 16); step('renames'); await v1('PATCH', `/courses/${s.courseId}`, { name: `MCP-Test Kurs [umbenannt ${stamp}]` }); log('course renamed'); await v3('PATCH', `/boards/${s.boardId}/title`, { title: `MCP-Test Board [umbenannt ${stamp}]` }); log('board renamed'); await v3('PATCH', `/columns/${s.columnId}/title`, { title: 'Material (überarbeitet)' }); await v3('PATCH', `/cards/${s.cardId}/title`, { title: 'Das Nervensystem — überarbeitet' }); log('column and card renamed'); await v1('PATCH', `/homework/${s.taskId}`, { name: `MCP-Test Aufgabe [umbenannt ${stamp}]` }); log('task renamed'); await v1('PATCH', `/lessons/${s.lessonId}`, { name: `MCP-Test Thema [umbenannt ${stamp}]` }); log('topic renamed'); if (s.teamId) { const team = await v1('GET', `/teams/${s.teamId}`); await v1('PATCH', `/teams/${s.teamId}`, { name: `${team.name.replace(/ \[MCP .*$/, '')} [MCP ${stamp}]` }); log('team renamed (the one write teams do allow)'); } await v3('PUT', `/rooms/${s.roomId}`, { name: `MCP-Test Raum [umbenannt ${stamp}]`, color: 'green', features: [] }); log('room renamed'); step('content edits'); await v3('PATCH', `/elements/${s.richTextId}/content`, { data: { content: { text: '

Ein Neuron leitet Reize weiter. Neuer Suchbegriff: Ranvierscher Schnürring.

', inputFormat: 'richTextCk5', }, type: 'richText', }, }); log('rich text rewritten (new search term: Ranvierscher Schnürring)'); await files('PATCH', `/rename/${s.fileId}`, { fileName: 'nervensystem-notiz-v2.txt' }); log('file renamed'); if (s.fmCourseFileId) { step('file manager edits'); await v1('POST', '/fileStorage/rename', { id: s.fmCourseFileId, newName: 'Blatt 1 - Zellorganellen (korrigiert).txt' }); log('course file renamed'); s.fmCourseAddedFileId = await legacyUpload({ owner: s.courseId, parent: s.fmCourseSubDirId, name: 'Blatt 3 - Organe.txt', type: 'text/plain', body: 'Blatt 3: Organe\n\nNeuer Suchbegriff: Nephronschleife.\n', }); log('course file added in "Woche 1" (new search term: Nephronschleife)'); } saveState({ ...s, updated: true }); } // ---------------------------------------------------------------- delete --- async function remove() { const s = loadState(); if (!s.boardId) throw new Error('nothing to delete — run `create` first'); step('deleting what was created'); const tries = [ ['file', () => files('DELETE', `/delete/${s.folderFileId}`)], ['room file', () => files('DELETE', `/delete/${s.roomFileId}`)], ['file element', () => v3('DELETE', `/elements/${s.fileElementId}`)], ['fileFolder element', () => v3('DELETE', `/elements/${s.folderId}`)], ['etherpad element', () => v3('DELETE', `/elements/${s.padElementId}`)], ['link element', () => v3('DELETE', `/elements/${s.linkId}`)], ['card', () => v3('DELETE', `/cards/${s.cardId}`)], ['column', () => v3('DELETE', `/columns/${s.columnId}`)], ['board', () => v3('DELETE', `/boards/${s.boardId}`)], ['draft board', () => v3('DELETE', `/boards/${s.draftBoardId}`)], ['task', () => v3('DELETE', `/tasks/${s.taskId}`)], ['topic', () => v3('DELETE', `/lessons/${s.lessonId}`)], ['room board', () => v3('DELETE', `/boards/${s.roomBoardId}`)], ['room', () => v3('DELETE', `/rooms/${s.roomId}`)], ['second room', () => v3('DELETE', `/rooms/${s.roomWithoutStudentId}`)], ['course', () => v1('DELETE', `/courses/${s.courseId}`)], ]; // File-manager content goes first, while its course and team still exist. const fileManagerTries = [ ['file-manager course files', async () => { for (const id of [s.fmCourseAddedFileId, s.fmCourseDeepFileId, s.fmCourseFileId, s.fmCourseRootFileId]) { if (id) await v1('DELETE', `/fileStorage?_id=${id}`); } }], ['file-manager course folders', async () => { for (const id of [s.fmCourseSubDirId, s.fmCourseDirId]) if (id) await v1('DELETE', `/fileStorage/directories?_id=${id}`); }], ['file-manager team content', async () => { if (s.fmTeamFileId) await v1('DELETE', `/fileStorage?_id=${s.fmTeamFileId}`); if (s.fmTeamDirId) await v1('DELETE', `/fileStorage/directories?_id=${s.fmTeamDirId}`); }], ['shared file', async () => { if (s.fmSharedFileId) await v1('DELETE', `/fileStorage?_id=${s.fmSharedFileId}`); }], ['student personal files', async () => { if (!s.fmStudentFileId && !s.fmStudentDirId) return; await asUser(STUDENT_EMAIL, STUDENT_PASSWORD, async () => { if (s.fmStudentFileId) await v1('DELETE', `/fileStorage?_id=${s.fmStudentFileId}`); if (s.fmStudentDirId) await v1('DELETE', `/fileStorage/directories?_id=${s.fmStudentDirId}`); }); }], ['student team membership', async () => { if (!s.fmStudentAddedToTeam || !s.teamId) return; const student = await studentId(); const team = await v1('GET', `/teams/${s.teamId}`); const userIds = team.userIds .map((entry) => ({ userId: String(entry.userId?._id ?? entry.userId), role: String(entry.role?._id ?? entry.role), schoolId: String(entry.schoolId?._id ?? entry.schoolId), })) .filter((entry) => entry.userId !== student); await v1('PATCH', `/teams/${s.teamId}`, { userIds }); }], ]; tries.unshift(...fileManagerTries); for (const [what, fn] of tries) { try { await fn(); log(`deleted ${what}`); } catch (err) { log(`could NOT delete ${what}: ${String(err.message).slice(0, 160)}`); } } unlinkSync(STATE); console.log(`\nstate file removed`); } function summary(s) { console.log('\nids for the MCP side:'); for (const [k, v] of Object.entries(s)) console.log(` ${k.padEnd(16)} ${v}`); } const phase = process.argv[2] ?? 'create'; await login(); if (phase === 'create') await create(); else if (phase === 'update') await update(); else if (phase === 'delete') await remove(); else if (phase === 'files') { await fileManager(); summary(loadState()); } else if (phase === 'show') summary(loadState()); else { console.error(`unknown phase ${phase}; expected create | update | delete | files | show`); process.exit(2); }