moodle-cli 0.7.0-alpha.5 → 0.7.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ONBOARDING.md CHANGED
@@ -19,7 +19,7 @@ Wait for the answer before doing setup work. Do not ask the student to find the
19
19
 
20
20
  Once the student shares a URL, complete the setup on their computer:
21
21
 
22
- - Check for Node.js 22+ and Bun. Install `moodle-cli` with the runtime available on the computer. Use `bunx --bun moodle-cli` as the command prefix when Bun is the only runtime.
22
+ - Check for Node.js 22.13+ and Bun (older Node cannot read browser cookies). Install `moodle-cli` with the runtime available on the computer. Use `bunx --bun moodle-cli` as the command prefix when Bun is the only runtime.
23
23
  - Follow redirects and resolve the URL to the final Moodle origin in the form `https://host`. Remove the path, query, and fragment. Confirm that the origin serves Moodle before saving it.
24
24
  - Read `~/.config/moodle-cli/config.yaml` if it exists. Set `base_url` to the verified origin and preserve the other settings.
25
25
  - Tell the student that Moodle may open in their browser and that you will wait while they complete their university sign-in. Run `moodle auth login`. If a browser opens, let the student finish SSO there, then continue when the command returns.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Let it keep up with deadlines and grades, fetch course files, and search forum d
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/moodle-cli?logo=npm)](https://www.npmjs.com/package/moodle-cli)
8
8
  [![CI](https://github.com/bunizao/moodle-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/bunizao/moodle-cli/actions/workflows/ci.yml)
9
- [![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?logo=nodedotjs&logoColor=white)](https://nodejs.org/)
9
+ [![Node.js 22.13+](https://img.shields.io/badge/Node.js-22.13%2B-339933?logo=nodedotjs&logoColor=white)](https://nodejs.org/)
10
10
  [![Bun](https://img.shields.io/badge/Bun-supported-fbf0df?logo=bun&logoColor=black)](https://bun.sh/)
11
11
  [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
12
12
 
@@ -32,7 +32,7 @@ Your agent asks for your Moodle URL and opens your university's sign-in page whe
32
32
 
33
33
  ### Install and sign in manually
34
34
 
35
- Use Node.js 22+ or Bun:
35
+ Use Node.js 22.13+ (needed for `node:sqlite`, which reads browser cookies) or Bun:
36
36
 
37
37
  ```bash
38
38
  # npm
@@ -176,7 +176,7 @@ moodle mcp bridge
176
176
 
177
177
  The default client connection uses `moodle mcp bridge`, which keeps the Bearer token out of client configuration. Use `moodle mcp connect CLIENT --mode remote` for clients that support authenticated remote MCP headers.
178
178
 
179
- Alpha version `0.7.0-alpha.5` supports MCP `2026-07-28` and a stateless compatibility lane for `2025-11-25` clients.
179
+ Alpha version `0.7.0-alpha.7` supports MCP `2026-07-28` and a stateless compatibility lane for `2025-11-25` clients.
180
180
 
181
181
  ### Configuration
182
182
 
package/dist/moodle.js CHANGED
@@ -222,8 +222,16 @@ async function getAuthenticatedSession(baseUrl, options = {}) {
222
222
  if (cached) {
223
223
  return cached;
224
224
  }
225
+ const cookieWarnings = [];
226
+ const providerOptions = {
227
+ ...options,
228
+ onCookieWarnings: (warnings) => {
229
+ cookieWarnings.push(...warnings);
230
+ options.onCookieWarnings?.(warnings);
231
+ }
232
+ };
225
233
  const browserProvider = options.browserCookieProvider ?? defaultBrowserCookieProvider;
226
- const browserCookies = matchingMoodleSessionCookies(await browserProvider(baseUrl, options), baseUrl);
234
+ const browserCookies = matchingMoodleSessionCookies(await browserProvider(baseUrl, providerOptions), baseUrl);
227
235
  const browserSession = await firstValidSession(baseUrl, browserCookies, validate);
228
236
  if (browserSession) {
229
237
  await refreshSessionCache(baseUrl, browserSession.cookie, browserSession.context, options);
@@ -247,10 +255,22 @@ async function getAuthenticatedSession(baseUrl, options = {}) {
247
255
  return { baseUrl, cookie: refreshedSession.cookie, ...refreshedSession.context, fromCache: false };
248
256
  }
249
257
  }
250
- throw new AuthError(`No usable MoodleSession found for ${baseUrl}.`, authFailureHint(baseUrl));
258
+ throw new AuthError(
259
+ `No usable MoodleSession found for ${baseUrl}.`,
260
+ authFailureHint(baseUrl, cookieWarnings, options.platform)
261
+ );
251
262
  }
252
263
  async function getAuthenticatedSessionWithBrowserFallback(baseUrl, options = {}) {
253
- const authOptions = { ...options, noCache: true, nonInteractive: true };
264
+ const cookieWarnings = [];
265
+ const authOptions = {
266
+ ...options,
267
+ noCache: true,
268
+ nonInteractive: true,
269
+ onCookieWarnings: (warnings) => {
270
+ cookieWarnings.push(...warnings);
271
+ options.onCookieWarnings?.(warnings);
272
+ }
273
+ };
254
274
  const browserAuthOptions = {
255
275
  ...authOptions,
256
276
  env: { ...options.env ?? process.env, [ENV_MOODLE_SESSION]: void 0 }
@@ -271,6 +291,12 @@ async function getAuthenticatedSessionWithBrowserFallback(baseUrl, options = {})
271
291
  }
272
292
  }
273
293
  }
294
+ if (cookieAccessBlocked(cookieWarnings)) {
295
+ throw new AuthError(
296
+ `Cannot read browser cookies for ${baseUrl}.`,
297
+ cookieAccessHint(cookieWarnings, options.platform)
298
+ );
299
+ }
274
300
  const url = loginUrl(baseUrl);
275
301
  await (options.openBrowser ?? ((target) => openSystemBrowser(target, options)))(url);
276
302
  options.onBrowserOpened?.(url);
@@ -330,7 +356,11 @@ async function defaultBrowserCookieProvider(baseUrl, options = {}) {
330
356
  mode: "merge"
331
357
  });
332
358
  const braveProfiles = await braveProfilePaths(options);
333
- const brave = braveProfiles.length ? await getCookies({ url: baseUrl, browsers: ["chrome"], chromeProfile: braveProfiles, mode: "merge" }) : { cookies: [] };
359
+ const brave = braveProfiles.length ? await getCookies({ url: baseUrl, browsers: ["chrome"], chromeProfile: braveProfiles, mode: "merge" }) : { cookies: [], warnings: [] };
360
+ const warnings = [...primary.warnings ?? [], ...brave.warnings ?? []];
361
+ if (warnings.length) {
362
+ options.onCookieWarnings?.(warnings);
363
+ }
334
364
  return [...primary.cookies, ...brave.cookies].map((cookie) => ({
335
365
  name: cookie.name,
336
366
  value: cookie.value,
@@ -345,7 +375,7 @@ async function braveProfilePaths(options = {}) {
345
375
  const roots = platform === "linux" ? [
346
376
  join2(home, ".config/BraveSoftware/Brave-Browser"),
347
377
  join2(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser")
348
- ] : platform === "win32" ? [join2(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data")] : [];
378
+ ] : platform === "win32" ? [join2(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data")] : platform === "darwin" ? [join2(home, "Library/Application Support/BraveSoftware/Brave-Browser")] : [];
349
379
  const profiles = [];
350
380
  for (const root of roots) {
351
381
  try {
@@ -375,13 +405,43 @@ async function loadSessionsFromOktaCli(baseUrl, options = {}, forceLogin = false
375
405
  const refreshed = await readOktaCookies(executable, baseUrl, execFile2);
376
406
  return refreshed.length ? refreshed : stored;
377
407
  }
378
- function authFailureHint(baseUrl) {
408
+ var COOKIE_ACCESS_DENIED = /EPERM|EACCES|operation not permitted|permission denied/i;
409
+ var COOKIE_SQLITE_UNAVAILABLE = /No such built-in module: node:sqlite/i;
410
+ var MINIMUM_NODE_FOR_BROWSER_COOKIES = "22.13.0";
411
+ function cookieAccessBlocked(warnings) {
412
+ return warnings.some((warning) => COOKIE_ACCESS_DENIED.test(warning) || COOKIE_SQLITE_UNAVAILABLE.test(warning));
413
+ }
414
+ function cookieAccessHint(warnings, platform = process.platform) {
415
+ const grant = platform === "darwin" ? "Grant Full Disk Access to the application running this command (System Settings > Privacy & Security > Full Disk Access), then restart it." : "Run this command as the user that owns the browser profile, or grant it read access to the browser cookie store.";
416
+ const remedy = warnings.some((warning) => COOKIE_SQLITE_UNAVAILABLE.test(warning)) ? [
417
+ `This Node.js runtime has no node:sqlite, which is needed to read browser cookies. Use Node.js ${MINIMUM_NODE_FOR_BROWSER_COOKIES} or newer, or run the CLI with Bun (bunx --bun moodle-cli).`
418
+ ] : [
419
+ "If this runs inside a sandboxed app (an IDE or agent terminal), rerun it from a regular terminal first.",
420
+ grant
421
+ ];
379
422
  return [
423
+ "The browser cookie store could not be read, so the session could not be detected.",
424
+ ...remedy,
425
+ `Alternatively set ${ENV_MOODLE_SESSION} to a valid MoodleSession cookie value.`,
426
+ "",
427
+ "Cookie store diagnostics:",
428
+ ...warnings.map((warning) => ` - ${warning}`)
429
+ ].join("\n");
430
+ }
431
+ function authFailureHint(baseUrl, cookieWarnings = [], platform = process.platform) {
432
+ if (cookieAccessBlocked(cookieWarnings)) {
433
+ return cookieAccessHint(cookieWarnings, platform);
434
+ }
435
+ const lines = [
380
436
  `Log in to ${loginUrl(baseUrl)} in your browser, then rerun the command.`,
381
437
  `Or set ${ENV_MOODLE_SESSION} to a valid MoodleSession cookie value.`,
382
438
  `For automatic login, install okta-auth: ${OKTA_AUTH_INSTALL_COMMAND}, then run ${OKTA_AUTH_CONFIG_COMMAND}.`,
383
439
  `okta-auth: ${OKTA_AUTH_URL}`
384
- ].join("\n");
440
+ ];
441
+ if (cookieWarnings.length) {
442
+ lines.push("", "Cookie store diagnostics:", ...cookieWarnings.map((warning) => ` - ${warning}`));
443
+ }
444
+ return lines.join("\n");
385
445
  }
386
446
  async function invalidateCachedSession(baseUrl, options = {}) {
387
447
  await deleteCachedSession(baseUrl, cacheOptions(options));
@@ -3745,8 +3805,8 @@ function keepaliveProgramArguments(execPath = process.execPath, argv1 = process.
3745
3805
  }
3746
3806
  return [execPath, resolvedArgv1, ...tail];
3747
3807
  }
3748
- function buildKeepalivePlist(programArguments, intervalMinutes, logPath) {
3749
- const args = programArguments.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n");
3808
+ function buildKeepalivePlist(programArguments2, intervalMinutes, logPath) {
3809
+ const args = programArguments2.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n");
3750
3810
  return [
3751
3811
  '<?xml version="1.0" encoding="UTF-8"?>',
3752
3812
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
@@ -3827,7 +3887,7 @@ function escapeXml(value) {
3827
3887
  }
3828
3888
 
3829
3889
  // src/version.ts
3830
- var VERSION = "0.7.0-alpha.5";
3890
+ var VERSION = "0.7.0-alpha.7";
3831
3891
 
3832
3892
  // src/forum.ts
3833
3893
  function parseDiscussionReference(value) {
@@ -4412,7 +4472,7 @@ function jsonCodec(container) {
4412
4472
  };
4413
4473
  }
4414
4474
  function jsonRegistration(connection) {
4415
- return connection.mode === "bridge" ? { command: connection.command, args: ["mcp", "bridge", "--profile", connection.profile] } : {
4475
+ return connection.mode === "bridge" ? { command: connection.command, args: connection.args } : {
4416
4476
  type: "http",
4417
4477
  url: connection.endpoint,
4418
4478
  headers: { Authorization: `Bearer ${connection.accessToken}` }
@@ -4426,7 +4486,7 @@ function tomlBlock(registration, connection) {
4426
4486
  if (connection.mode === "bridge") {
4427
4487
  lines.push(
4428
4488
  `command = ${JSON.stringify(connection.command)}`,
4429
- `args = ${JSON.stringify(["mcp", "bridge", "--profile", connection.profile])}`
4489
+ `args = ${JSON.stringify(connection.args)}`
4430
4490
  );
4431
4491
  } else {
4432
4492
  lines.push(
@@ -4478,7 +4538,11 @@ function validateProfile(profile) {
4478
4538
  }
4479
4539
  function resolveConnection(options) {
4480
4540
  if (options.mode !== "remote") {
4481
- return { mode: "bridge", command: options.command ?? "moodle", profile: options.profile };
4541
+ return {
4542
+ mode: "bridge",
4543
+ command: options.command ?? "moodle",
4544
+ args: [...options.commandArgs ?? [], "mcp", "bridge", "--profile", options.profile]
4545
+ };
4482
4546
  }
4483
4547
  if (!options.endpoint || !options.accessToken) {
4484
4548
  throw new Error("Remote MCP connection requires an endpoint and access token");
@@ -4498,6 +4562,15 @@ function resolveConnection(options) {
4498
4562
  return { mode: "remote", endpoint: endpoint.toString(), accessToken: options.accessToken };
4499
4563
  }
4500
4564
 
4565
+ // src/mcp/self-command.ts
4566
+ function selfCommand(argv = process.argv, execPath = process.execPath) {
4567
+ const script = argv[1];
4568
+ return script && script !== execPath ? { command: execPath, args: [script] } : { command: execPath, args: [] };
4569
+ }
4570
+ function runtimeCommand(command, args) {
4571
+ return command ? { command, args: [...args ?? []] } : selfCommand();
4572
+ }
4573
+
4501
4574
  // src/mcp/connectors/node-connectors.ts
4502
4575
  import { chmod as chmod2, mkdir as mkdir4, readFile as readFile3, rm as rm3, stat as stat2, writeFile as writeFile4 } from "fs/promises";
4503
4576
  import { homedir as homedir5 } from "os";
@@ -4530,9 +4603,11 @@ function createDefaultClientConnectors(profile, options = {}) {
4530
4603
  const home = options.homeDirectory ?? homedir5();
4531
4604
  const platform = options.platform ?? process.platform;
4532
4605
  const fileSystem = options.fileSystem ?? new NodeConnectorFileSystem();
4606
+ const runtime = runtimeCommand(options.command, options.commandArgs);
4533
4607
  const shared = {
4534
4608
  profile,
4535
- command: options.command,
4609
+ command: runtime.command,
4610
+ commandArgs: runtime.args,
4536
4611
  mode: options.mode,
4537
4612
  endpoint: options.endpoint,
4538
4613
  accessToken: options.accessToken
@@ -5279,8 +5354,8 @@ var DeploymentPlanError = class extends Error {
5279
5354
  code;
5280
5355
  };
5281
5356
  var DeploymentApplyError = class extends Error {
5282
- constructor(code, message) {
5283
- super(message);
5357
+ constructor(code, message, options) {
5358
+ super(message, options);
5284
5359
  this.code = code;
5285
5360
  this.name = "DeploymentApplyError";
5286
5361
  }
@@ -5309,7 +5384,7 @@ var ManagedMcpDeployment = class {
5309
5384
  const receipt = replacingExisting || remote === null ? null : matchingReceipt;
5310
5385
  const existing = remote && receipt ? { ...remote, productionEndpoint: receipt.productionEndpoint, releaseDigest: receipt.releaseDigest } : remote;
5311
5386
  const rotate = intent.rotateToken === true && credentials !== null;
5312
- const releaseChanged = existing?.releaseDigest !== intent.releaseDigest;
5387
+ const releaseChanged = existing?.releaseDigest !== intent.releaseDigest || receipt?.restoredRelease === true;
5313
5388
  const uploadCandidate = !existing || replacingExisting || releaseChanged || intent.repair === true || rotate;
5314
5389
  return {
5315
5390
  intent: { ...intent },
@@ -5356,20 +5431,13 @@ var ManagedMcpDeployment = class {
5356
5431
  yield completed(activeStage);
5357
5432
  activeStage = "upload_private_credentials";
5358
5433
  yield started(activeStage);
5359
- if (plan.uploadCandidate) {
5360
- if (plan.operation === "create") {
5361
- initializedWorker = await this.dependencies.wrangler.initializeWorker({
5362
- accountId: plan.intent.accountId,
5363
- workerName: plan.intent.workerName,
5364
- configPath: prepared.wranglerConfigPath,
5365
- releaseDigest: plan.intent.releaseDigest
5366
- });
5367
- }
5368
- await this.dependencies.wrangler.uploadSecrets({
5434
+ if (plan.uploadCandidate && plan.operation === "create") {
5435
+ initializedWorker = await this.dependencies.wrangler.initializeWorker({
5369
5436
  accountId: plan.intent.accountId,
5370
5437
  workerName: plan.intent.workerName,
5371
5438
  configPath: prepared.wranglerConfigPath,
5372
- secretsFilePath: prepared.secretsFilePath
5439
+ secretsFilePath: prepared.secretsFilePath,
5440
+ releaseDigest: plan.intent.releaseDigest
5373
5441
  });
5374
5442
  secretsUploaded = true;
5375
5443
  }
@@ -5385,14 +5453,17 @@ var ManagedMcpDeployment = class {
5385
5453
  accountId: plan.intent.accountId,
5386
5454
  workerName: plan.intent.workerName,
5387
5455
  configPath: prepared.wranglerConfigPath,
5456
+ secretsFilePath: prepared.secretsFilePath,
5388
5457
  releaseDigest: plan.intent.releaseDigest,
5389
5458
  productionEndpoint: productionEndpoint2
5390
5459
  });
5460
+ secretsUploaded = true;
5391
5461
  if (!candidate.previewEndpoint) {
5392
5462
  await this.dependencies.wrangler.promote({
5393
5463
  accountId: plan.intent.accountId,
5394
5464
  workerName: plan.intent.workerName,
5395
- versionId: candidate.versionId
5465
+ versionId: candidate.versionId,
5466
+ releaseDigest: plan.intent.releaseDigest
5396
5467
  });
5397
5468
  promoted = true;
5398
5469
  }
@@ -5434,7 +5505,8 @@ var ManagedMcpDeployment = class {
5434
5505
  await this.dependencies.wrangler.promote({
5435
5506
  accountId: plan.intent.accountId,
5436
5507
  workerName: plan.intent.workerName,
5437
- versionId: candidate.versionId
5508
+ versionId: candidate.versionId,
5509
+ releaseDigest: plan.intent.releaseDigest
5438
5510
  });
5439
5511
  promoted = true;
5440
5512
  }
@@ -5535,15 +5607,14 @@ var ManagedMcpDeployment = class {
5535
5607
  let readinessReasonCode = null;
5536
5608
  let sessionRevision = null;
5537
5609
  if (worker && credentials) {
5538
- const remoteReadiness = await this.dependencies.worker.getReadiness({
5539
- endpoint: receipt.productionEndpoint,
5540
- sessionSyncToken: credentials.sessionSyncToken
5541
- });
5610
+ const target = { endpoint: receipt.productionEndpoint, sessionSyncToken: credentials.sessionSyncToken };
5611
+ await this.dependencies.worker.touchSession(target);
5612
+ const remoteReadiness = await this.dependencies.worker.getReadiness(target);
5542
5613
  readiness = remoteReadiness.status;
5543
5614
  readinessReasonCode = remoteReadiness.reasonCode;
5544
5615
  sessionRevision = remoteReadiness.revision;
5545
5616
  }
5546
- const resolvedWorker = worker ? { ...worker, productionEndpoint: receipt.productionEndpoint } : null;
5617
+ const resolvedWorker = worker ? { ...worker, productionEndpoint: receipt.productionEndpoint, releaseDigest: worker.releaseDigest || receipt.releaseDigest } : null;
5547
5618
  return {
5548
5619
  profile,
5549
5620
  worker: resolvedWorker,
@@ -5606,6 +5677,7 @@ var ManagedMcpDeployment = class {
5606
5677
  await this.dependencies.receipts.write({
5607
5678
  ...receipt,
5608
5679
  productionVersionId: worker.previousHealthyVersionId,
5680
+ ...swappedDigests(receipt),
5609
5681
  sessionRevision: upload.revision
5610
5682
  });
5611
5683
  await this.reconcileLocalIntegrations(profile);
@@ -5652,7 +5724,8 @@ var ManagedMcpDeployment = class {
5652
5724
  }
5653
5725
  await this.dependencies.receipts.write({
5654
5726
  ...receipt,
5655
- productionVersionId: previousVersionId
5727
+ productionVersionId: previousVersionId,
5728
+ ...swappedDigests(receipt)
5656
5729
  });
5657
5730
  return { status: "restored", versionId: previousVersionId };
5658
5731
  }
@@ -5759,6 +5832,7 @@ function makeReceipt(plan, candidate, sessionRevision) {
5759
5832
  productionEndpoint: candidate.productionEndpoint,
5760
5833
  productionVersionId: candidate.versionId,
5761
5834
  releaseDigest: plan.intent.releaseDigest,
5835
+ ...previousDigest(plan.existing?.releaseDigest),
5762
5836
  sessionRevision
5763
5837
  };
5764
5838
  }
@@ -5774,9 +5848,16 @@ function makeReceipt(plan, candidate, sessionRevision) {
5774
5848
  productionEndpoint: plan.existing.productionEndpoint,
5775
5849
  productionVersionId: plan.existing.productionVersionId,
5776
5850
  releaseDigest: plan.existing.releaseDigest,
5851
+ ...previousDigest(plan.receipt?.previousReleaseDigest),
5777
5852
  sessionRevision
5778
5853
  };
5779
5854
  }
5855
+ function previousDigest(digest2) {
5856
+ return digest2 === void 0 ? {} : { previousReleaseDigest: digest2 };
5857
+ }
5858
+ function swappedDigests(receipt) {
5859
+ return { releaseDigest: receipt.previousReleaseDigest ?? "", previousReleaseDigest: receipt.releaseDigest, restoredRelease: true };
5860
+ }
5780
5861
  function started(stageId) {
5781
5862
  return event(stageId, "started");
5782
5863
  }
@@ -5797,7 +5878,8 @@ function asDeploymentError(error) {
5797
5878
  if (error instanceof DeploymentApplyError) {
5798
5879
  return error;
5799
5880
  }
5800
- return new DeploymentApplyError("DEPLOYMENT_FAILED", "The managed Moodle MCP deployment failed");
5881
+ const detail = error instanceof Error && error.message ? `: ${error.message}` : "";
5882
+ return new DeploymentApplyError("DEPLOYMENT_FAILED", `The managed Moodle MCP deployment failed${detail}`, { cause: error });
5801
5883
  }
5802
5884
 
5803
5885
  // src/mcp/deployment/node-adapters.ts
@@ -5945,17 +6027,19 @@ function macOSPlan(options, intervalMinutes) {
5945
6027
  const label = `com.moodle-cli.mcp-renewal.${options.profile}`;
5946
6028
  const path4 = `${trimEnd(options.homeDirectory, "/")}/Library/LaunchAgents/${label}.plist`;
5947
6029
  const target = `gui/${options.uid}`;
5948
- const args = renewalArgs(options.profile);
6030
+ const logPath = `${trimEnd(options.homeDirectory, "/")}/Library/Logs/${label}.log`;
5949
6031
  const plist = [
5950
6032
  '<?xml version="1.0" encoding="UTF-8"?>',
5951
6033
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
5952
6034
  '<plist version="1.0"><dict>',
5953
6035
  `<key>Label</key><string>${xml(label)}</string>`,
5954
6036
  "<key>ProgramArguments</key><array>",
5955
- ...[options.executable, ...args].map((arg) => `<string>${xml(arg)}</string>`),
6037
+ ...programArguments(options).map((arg) => `<string>${xml(arg)}</string>`),
5956
6038
  "</array>",
5957
6039
  `<key>StartInterval</key><integer>${intervalMinutes * 60}</integer>`,
5958
6040
  "<key>RunAtLoad</key><true/>",
6041
+ `<key>StandardOutPath</key><string>${xml(logPath)}</string>`,
6042
+ `<key>StandardErrorPath</key><string>${xml(logPath)}</string>`,
5959
6043
  "</dict></plist>",
5960
6044
  ""
5961
6045
  ].join("\n");
@@ -5976,7 +6060,7 @@ function linuxPlan(options, intervalMinutes) {
5976
6060
  const directory = `${trimEnd(options.homeDirectory, "/")}/.config/systemd/user`;
5977
6061
  const servicePath = `${directory}/${label}.service`;
5978
6062
  const timerPath = `${directory}/${label}.timer`;
5979
- const command = [options.executable, ...renewalArgs(options.profile)].map(systemdQuote).join(" ");
6063
+ const command = programArguments(options).map(systemdQuote).join(" ");
5980
6064
  const service = [
5981
6065
  "[Unit]",
5982
6066
  `Description=Moodle MCP session renewal (${options.profile})`,
@@ -6020,7 +6104,7 @@ function linuxPlan(options, intervalMinutes) {
6020
6104
  function windowsPlan(options, intervalMinutes) {
6021
6105
  const label = `Moodle CLI MCP Renewal (${options.profile})`;
6022
6106
  const path4 = `${trimEnd(options.homeDirectory, "\\/")}\\AppData\\Local\\moodle-cli\\renewal\\${options.profile}.xml`;
6023
- const argumentsText = renewalArgs(options.profile).map(windowsArgument).join(" ");
6107
+ const argumentsText = [...options.executableArgs ?? [], ...renewalArgs(options.profile)].map(windowsArgument).join(" ");
6024
6108
  const task = [
6025
6109
  '<?xml version="1.0" encoding="UTF-8"?>',
6026
6110
  '<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
@@ -6047,6 +6131,9 @@ function windowsPlan(options, intervalMinutes) {
6047
6131
  function renewalArgs(profile) {
6048
6132
  return ["mcp", "renewal", "run", "--profile", profile, "--json"];
6049
6133
  }
6134
+ function programArguments(options) {
6135
+ return [options.executable, ...options.executableArgs ?? [], ...renewalArgs(options.profile)];
6136
+ }
6050
6137
  function validateOptions(options) {
6051
6138
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(options.profile)) {
6052
6139
  throw new Error("Invalid renewal profile name");
@@ -6054,6 +6141,9 @@ function validateOptions(options) {
6054
6141
  if (!options.executable || /[\r\n]/.test(options.executable)) {
6055
6142
  throw new Error("Invalid renewal executable path");
6056
6143
  }
6144
+ if ((options.executableArgs ?? []).some((arg) => /[\r\n]/.test(arg))) {
6145
+ throw new Error("Invalid renewal executable arguments");
6146
+ }
6057
6147
  if (!options.homeDirectory || /[\r\n]/.test(options.homeDirectory)) {
6058
6148
  throw new Error("Invalid renewal home directory");
6059
6149
  }
@@ -6128,10 +6218,12 @@ function createDefaultRenewalInstaller(profile, options = {}) {
6128
6218
  if (!isSupportedPlatform(platform)) {
6129
6219
  throw new Error(`Moodle MCP renewal is not supported on ${platform}`);
6130
6220
  }
6221
+ const runtime = runtimeCommand(options.executable, options.executableArgs);
6131
6222
  const plan = buildRenewalInstallPlan({
6132
6223
  platform,
6133
6224
  profile,
6134
- executable: options.executable ?? process.argv[1] ?? process.execPath,
6225
+ executable: runtime.command,
6226
+ executableArgs: runtime.args,
6135
6227
  homeDirectory: options.homeDirectory ?? homedir7(),
6136
6228
  uid: options.uid ?? (typeof process.getuid === "function" ? process.getuid() : void 0),
6137
6229
  intervalMinutes: options.intervalMinutes
@@ -6242,7 +6334,7 @@ var NodeDeploymentCommandRunner = class {
6242
6334
  };
6243
6335
  var WranglerCommandError = class extends Error {
6244
6336
  constructor(exitCode, stdout, stderr) {
6245
- super("Packaged Wrangler command failed");
6337
+ super(wranglerFailureMessage(stderr, stdout));
6246
6338
  this.exitCode = exitCode;
6247
6339
  this.stdout = stdout;
6248
6340
  this.stderr = stderr;
@@ -6252,6 +6344,13 @@ var WranglerCommandError = class extends Error {
6252
6344
  stdout;
6253
6345
  stderr;
6254
6346
  };
6347
+ function wranglerFailureMessage(stderr, stdout) {
6348
+ const lines = `${stderr}
6349
+ ${stdout}`.replace(/\u001B\[[0-9;]*m/gu, "").split(/\r?\n/u).map((line) => line.trim());
6350
+ const errorIndex = lines.findIndex((line) => line.includes("[ERROR]"));
6351
+ const detail = errorIndex >= 0 ? lines.slice(errorIndex).filter((line) => line && !/^(To learn more|If you think this is a bug|Logs were written)/u.test(line)).slice(0, 3).join(" ").replace(/^.*\[ERROR\]\s*/u, "") : "";
6352
+ return detail ? `Packaged Wrangler command failed: ${detail.slice(0, 600)}` : "Packaged Wrangler command failed";
6353
+ }
6255
6354
  var NodeWranglerDeploymentAdapter = class {
6256
6355
  wranglerBinPath;
6257
6356
  runner;
@@ -6294,8 +6393,8 @@ ${error.stderr}`)) {
6294
6393
  throw error;
6295
6394
  }
6296
6395
  const document = parseJsonOutput(result.stdout);
6297
- const versionIds = deploymentVersionIds(document);
6298
- if (!versionIds.length) {
6396
+ const [current, previous] = deploymentHistory(document);
6397
+ if (!current) {
6299
6398
  return null;
6300
6399
  }
6301
6400
  const productionEndpoint = firstWorkersDevUrl(document) ?? `https://${workerName}.workers.dev`;
@@ -6306,22 +6405,11 @@ ${error.stderr}`)) {
6306
6405
  deploymentId: deploymentId2,
6307
6406
  ownershipTag: deploymentId2,
6308
6407
  productionEndpoint,
6309
- productionVersionId: versionIds[0],
6310
- previousHealthyVersionId: versionIds[1] ?? null,
6311
- releaseDigest: releaseDigestFromDocument(document) ?? ""
6408
+ productionVersionId: current.versionId,
6409
+ previousHealthyVersionId: previous?.versionId ?? null,
6410
+ releaseDigest: releaseDigestFromMessage(current.message) ?? ""
6312
6411
  };
6313
6412
  }
6314
- async uploadSecrets(input) {
6315
- await this.wrangler([
6316
- "secret",
6317
- "bulk",
6318
- input.secretsFilePath,
6319
- "--name",
6320
- input.workerName,
6321
- "--config",
6322
- input.configPath
6323
- ], input.accountId);
6324
- }
6325
6413
  async initializeWorker(input) {
6326
6414
  let result = null;
6327
6415
  try {
@@ -6331,6 +6419,8 @@ ${error.stderr}`)) {
6331
6419
  input.workerName,
6332
6420
  "--config",
6333
6421
  input.configPath,
6422
+ "--secrets-file",
6423
+ input.secretsFilePath,
6334
6424
  "--message",
6335
6425
  `moodle-cli-bootstrap:${input.releaseDigest}`
6336
6426
  ], input.accountId);
@@ -6365,6 +6455,8 @@ ${error.stderr}`)) {
6365
6455
  input.workerName,
6366
6456
  "--config",
6367
6457
  input.configPath,
6458
+ "--secrets-file",
6459
+ input.secretsFilePath,
6368
6460
  "--preview-alias",
6369
6461
  "moodle-cli-candidate",
6370
6462
  "--message",
@@ -6386,6 +6478,8 @@ ${error.stderr}`)) {
6386
6478
  await rm6(outputFilePath, { force: true });
6387
6479
  }
6388
6480
  }
6481
+ // restoreProduction re-deploys an older version whose digest is unknown, so the
6482
+ // release annotation is only written when the caller knows it.
6389
6483
  async promote(input) {
6390
6484
  await this.wrangler([
6391
6485
  "versions",
@@ -6393,7 +6487,8 @@ ${error.stderr}`)) {
6393
6487
  `${input.versionId}@100`,
6394
6488
  "--name",
6395
6489
  input.workerName,
6396
- "--yes"
6490
+ "--yes",
6491
+ ...input.releaseDigest ? ["--message", `moodle-cli-release:${input.releaseDigest}`] : []
6397
6492
  ], input.accountId);
6398
6493
  }
6399
6494
  async restoreProduction(input) {
@@ -6488,7 +6583,7 @@ var DefaultMoodleSessionSource = class {
6488
6583
  }
6489
6584
  options;
6490
6585
  async loadValidated(_profile, moodleOrigin) {
6491
- const session = this.options.interactive === false ? await getAuthenticatedSession(moodleOrigin, { ...this.options, nonInteractive: true }) : await getAuthenticatedSessionWithBrowserFallback(moodleOrigin, this.options);
6586
+ const session = this.options.interactive === false ? await getAuthenticatedSession(moodleOrigin, { ...this.options, nonInteractive: true, noCache: true }) : await getAuthenticatedSessionWithBrowserFallback(moodleOrigin, this.options);
6492
6587
  return {
6493
6588
  moodleOrigin,
6494
6589
  cookieName: session.cookie.name,
@@ -6530,7 +6625,7 @@ var FetchManagedWorkerClient = class {
6530
6625
  return { revision: body.revision };
6531
6626
  }
6532
6627
  const code = isRecord8(body) && typeof body.code === "string" ? body.code : "SESSION_UPLOAD_FAILED";
6533
- throw new DeploymentApplyError(code, "The Worker rejected the Moodle session update");
6628
+ throw new DeploymentApplyError(code, `The Worker rejected the Moodle session update (${code})`);
6534
6629
  }
6535
6630
  async getReadiness(input) {
6536
6631
  const response = await this.fetchWithRetry(endpointUrl(input.endpoint, "/readyz"), {
@@ -6548,6 +6643,12 @@ var FetchManagedWorkerClient = class {
6548
6643
  }
6549
6644
  return { status: "fail", reasonCode: null, revision: null };
6550
6645
  }
6646
+ async touchSession(input) {
6647
+ await this.fetchWithRetry(endpointUrl(input.endpoint, "/session/touch"), {
6648
+ method: "POST",
6649
+ headers: { authorization: `Bearer ${input.sessionSyncToken}` }
6650
+ }, isRetryableWorkerRouting);
6651
+ }
6551
6652
  async runSmoke(input) {
6552
6653
  const health = await this.fetchWithRetry(
6553
6654
  endpointUrl(input.endpoint, "/healthz"),
@@ -6657,7 +6758,7 @@ var PrivateDeploymentReceiptStore = class {
6657
6758
  function createDefaultManagedDeployment(options) {
6658
6759
  const homeDirectory = options.homeDirectory ?? homedir8();
6659
6760
  const platform = options.platform ?? process.platform;
6660
- const executable = options.executable ?? process.argv[1] ?? process.execPath;
6761
+ const runtime = runtimeCommand(options.executable, options.executableArgs);
6661
6762
  const defaults = {
6662
6763
  wrangler: new NodeWranglerDeploymentAdapter({ wranglerBinPath: options.wranglerBinPath }),
6663
6764
  materializer: new NodeReleaseMaterializer({
@@ -6671,14 +6772,16 @@ function createDefaultManagedDeployment(options) {
6671
6772
  ...options.renewal,
6672
6773
  platform,
6673
6774
  homeDirectory,
6674
- executable,
6775
+ executable: runtime.command,
6776
+ executableArgs: runtime.args,
6675
6777
  uid: options.uid
6676
6778
  }),
6677
6779
  clients: new DefaultClientIntegration({
6678
6780
  ...options.connector,
6679
6781
  platform,
6680
6782
  homeDirectory,
6681
- command: executable
6783
+ command: runtime.command,
6784
+ commandArgs: runtime.args
6682
6785
  }),
6683
6786
  receipts: new PrivateDeploymentReceiptStore(join7(homeDirectory, ".config", "moodle-cli", "mcp", "deployments")),
6684
6787
  createToken: () => randomBytes(32).toString("base64url")
@@ -6737,6 +6840,9 @@ function isRetryableSessionUpload(status) {
6737
6840
  function isRetryableWorkerPropagation(status) {
6738
6841
  return status === 404 || status === 429 || status >= 500;
6739
6842
  }
6843
+ function isRetryableWorkerRouting(status) {
6844
+ return status === 404 || status === 429;
6845
+ }
6740
6846
  async function safeJson(response) {
6741
6847
  try {
6742
6848
  return await response.json();
@@ -6789,30 +6895,35 @@ async function readWranglerVersionUpload(outputFilePath, workerName) {
6789
6895
  }
6790
6896
  throw new DeploymentApplyError("CANDIDATE_UPLOAD_INVALID", "Wrangler wrote unsupported candidate metadata");
6791
6897
  }
6792
- function collectStrings(value, keys) {
6793
- const result = [];
6794
- visit(value, (key, item) => {
6795
- if (keys.has(key) && typeof item === "string") {
6796
- result.push(item);
6898
+ function deploymentHistory(value) {
6899
+ const entries = [];
6900
+ visit(value, (_key, item) => {
6901
+ if (!isRecord8(item) || !Array.isArray(item.versions)) {
6902
+ return;
6797
6903
  }
6904
+ const versionId = activeVersionId(item.versions);
6905
+ if (!versionId) {
6906
+ return;
6907
+ }
6908
+ const createdOn = typeof item.created_on === "string" ? Date.parse(item.created_on) : Number.NaN;
6909
+ const message = isRecord8(item.annotations) ? item.annotations["workers/message"] : void 0;
6910
+ entries.push({
6911
+ versionId,
6912
+ message: typeof message === "string" ? message : null,
6913
+ createdOn: Number.isNaN(createdOn) ? 0 : createdOn,
6914
+ index: entries.length
6915
+ });
6798
6916
  });
6799
- return result;
6917
+ return entries.sort((a, b) => b.createdOn - a.createdOn || b.index - a.index).map(({ versionId, message }) => ({ versionId, message }));
6800
6918
  }
6801
- function deploymentVersionIds(value) {
6802
- const ids = collectStrings(value, /* @__PURE__ */ new Set(["version_id", "versionId"]));
6803
- return [...new Set(ids)];
6919
+ function activeVersionId(versions) {
6920
+ const records = versions.filter(isRecord8);
6921
+ const active = records.find((version) => version.percentage === 100) ?? records[0];
6922
+ const id = active?.version_id ?? active?.versionId;
6923
+ return typeof id === "string" ? id : null;
6804
6924
  }
6805
- function releaseDigestFromDocument(value) {
6806
- let digestValue = null;
6807
- visit(value, (_key, item) => {
6808
- if (!digestValue && typeof item === "string") {
6809
- const match = item.match(/moodle-cli-release:([a-zA-Z0-9._-]+)/u);
6810
- if (match?.[1]) {
6811
- digestValue = match[1];
6812
- }
6813
- }
6814
- });
6815
- return digestValue;
6925
+ function releaseDigestFromMessage(message) {
6926
+ return message?.match(/moodle-cli-release:([a-zA-Z0-9._-]+)/u)?.[1] ?? null;
6816
6927
  }
6817
6928
  function collectAccountObjects(value) {
6818
6929
  const accounts = [];
@@ -7744,8 +7855,7 @@ var DefaultMcpCommandService = class {
7744
7855
  this.worker = options.worker ?? new FetchManagedWorkerClient(options.fetchImpl);
7745
7856
  this.renewal = options.renewal ?? new DefaultRenewalIntegration({
7746
7857
  platform: process.platform,
7747
- homeDirectory: this.homeDirectory,
7748
- executable: process.argv[1] ?? process.execPath
7858
+ homeDirectory: this.homeDirectory
7749
7859
  });
7750
7860
  this.sessions = options.sessions ?? createBackgroundMoodleSessionSource({
7751
7861
  env: options.env,
@@ -7908,7 +8018,6 @@ var DefaultMcpCommandService = class {
7908
8018
  const connectors = createDefaultClientConnectors(profile, {
7909
8019
  homeDirectory: this.homeDirectory,
7910
8020
  platform: process.platform,
7911
- command: process.argv[1] ?? "moodle",
7912
8021
  mode: input.mode,
7913
8022
  ...input.mode === "remote" ? { endpoint, accessToken: credentials.mcpAccessToken } : {}
7914
8023
  });
@@ -7982,10 +8091,9 @@ var DefaultMcpCommandService = class {
7982
8091
  throw new UsageError(`No managed Moodle MCP deployment exists for profile ${profile}.`);
7983
8092
  }
7984
8093
  let receipt = storedReceipt;
7985
- const readiness = await this.worker.getReadiness({
7986
- endpoint: receipt.productionEndpoint,
7987
- sessionSyncToken: credentials.sessionSyncToken
7988
- });
8094
+ const target = { endpoint: receipt.productionEndpoint, sessionSyncToken: credentials.sessionSyncToken };
8095
+ await this.worker.touchSession(target);
8096
+ const readiness = await this.worker.getReadiness(target);
7989
8097
  if (readiness.revision !== null && readiness.revision !== receipt.sessionRevision) {
7990
8098
  receipt = await this.writeRenewalRevision(receipt, readiness.revision);
7991
8099
  }
@@ -7997,6 +8105,7 @@ var DefaultMcpCommandService = class {
7997
8105
  agentInstalled: await this.renewal.inspect(profile)
7998
8106
  };
7999
8107
  let replacement = null;
8108
+ let signInDetail;
8000
8109
  if (snapshot.remote === "expiring" || snapshot.remote === "expired") {
8001
8110
  try {
8002
8111
  replacement = await this.sessions.loadValidated(profile, receipt.moodleOrigin);
@@ -8006,6 +8115,7 @@ var DefaultMcpCommandService = class {
8006
8115
  throw error;
8007
8116
  }
8008
8117
  snapshot.replacement = { source: "mfa_required" };
8118
+ signInDetail = [error.message, error.hint].filter(Boolean).join(" ");
8009
8119
  }
8010
8120
  }
8011
8121
  let uploaded = false;
@@ -8058,9 +8168,10 @@ var DefaultMcpCommandService = class {
8058
8168
  text: "Moodle MCP session renewed."
8059
8169
  };
8060
8170
  }
8171
+ const detail = decision.state === "needs_sign_in" && signInDetail ? { detail: signInDetail } : {};
8061
8172
  return {
8062
- data: { profile, state: decision.state, reasonCode: decision.reasonCode, revision: receipt.sessionRevision },
8063
- text: renewalResultText(decision)
8173
+ data: { profile, state: decision.state, reasonCode: decision.reasonCode, revision: receipt.sessionRevision, ...detail },
8174
+ text: [renewalResultText(decision), signInDetail].filter(Boolean).join("\n")
8064
8175
  };
8065
8176
  }
8066
8177
  async writeRenewalRevision(receipt, revision) {
@@ -8111,7 +8222,6 @@ var DefaultMcpCommandService = class {
8111
8222
  compatibilityDate: this.options.compatibilityDate ?? WORKER_COMPATIBILITY_DATE,
8112
8223
  homeDirectory: this.homeDirectory,
8113
8224
  platform: process.platform,
8114
- executable: process.argv[1] ?? process.execPath,
8115
8225
  fetch: this.options.fetchImpl,
8116
8226
  auth: {
8117
8227
  env: this.options.env,
@@ -8183,8 +8293,7 @@ Selection: `)).trim());
8183
8293
  async connectedClientNames(profile) {
8184
8294
  const connectors = createDefaultClientConnectors(profile, {
8185
8295
  homeDirectory: this.homeDirectory,
8186
- platform: process.platform,
8187
- command: process.argv[1] ?? "moodle"
8296
+ platform: process.platform
8188
8297
  });
8189
8298
  const clients = [];
8190
8299
  for (const connector of connectors) {
@@ -20597,7 +20597,7 @@ function stringValue(value) {
20597
20597
  }
20598
20598
 
20599
20599
  // src/version.ts
20600
- var VERSION = "0.7.0-alpha.5";
20600
+ var VERSION = "0.7.0-alpha.7";
20601
20601
 
20602
20602
  // src/worker/http.ts
20603
20603
  var HEALTH_PATH = "/healthz";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moodle-cli",
3
- "version": "0.7.0-alpha.5",
3
+ "version": "0.7.0-alpha.7",
4
4
  "description": "Terminal-first CLI for Moodle LMS",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,7 +47,7 @@
47
47
  "vitest": "^3.2.4"
48
48
  },
49
49
  "engines": {
50
- "node": ">=22"
50
+ "node": ">=22.13"
51
51
  },
52
52
  "repository": {
53
53
  "type": "git",