@rubytech/create-maxy-code 0.1.367 → 0.1.369

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.
Files changed (33) hide show
  1. package/dist/__tests__/cron-heartbeat-registration.test.js +100 -0
  2. package/dist/cron-registration.js +72 -0
  3. package/dist/index.js +68 -3
  4. package/dist/uninstall.js +31 -0
  5. package/package.json +1 -1
  6. package/payload/platform/plugins/email/.claude-plugin/plugin.json +1 -1
  7. package/payload/platform/plugins/email/PLUGIN.md +5 -1
  8. package/payload/platform/plugins/email/mcp/dist/__tests__/email-delete.test.d.ts +2 -0
  9. package/payload/platform/plugins/email/mcp/dist/__tests__/email-delete.test.d.ts.map +1 -0
  10. package/payload/platform/plugins/email/mcp/dist/__tests__/email-delete.test.js +151 -0
  11. package/payload/platform/plugins/email/mcp/dist/__tests__/email-delete.test.js.map +1 -0
  12. package/payload/platform/plugins/email/mcp/dist/index.js +29 -0
  13. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  14. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +23 -0
  15. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  16. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +66 -0
  17. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  18. package/payload/platform/plugins/email/mcp/dist/tools/email-delete.d.ts +21 -0
  19. package/payload/platform/plugins/email/mcp/dist/tools/email-delete.d.ts.map +1 -0
  20. package/payload/platform/plugins/email/mcp/dist/tools/email-delete.js +51 -0
  21. package/payload/platform/plugins/email/mcp/dist/tools/email-delete.js.map +1 -0
  22. package/payload/platform/plugins/email/references/email-reference.md +10 -0
  23. package/payload/platform/plugins/email/skills/email-composition/SKILL.md +21 -4
  24. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
  25. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.d.ts +5 -5
  26. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +5 -5
  27. package/payload/platform/plugins/workflows/PLUGIN.md +2 -2
  28. package/payload/platform/plugins/workflows/skills/workflow-manager/SKILL.md +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  30. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  31. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  32. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  33. package/payload/platform/templates/specialists/agents/project-manager.md +1 -1
@@ -0,0 +1,100 @@
1
+ // Acceptance gate for the restored scheduling-watcher heartbeat cron.
2
+ //
3
+ // The installer registers exactly ONE install-level cron line that ticks
4
+ // check-due-events.js once a minute. These invariants protect the two
5
+ // regressions that the original block caused (and that its narrowed
6
+ // replacement must not reintroduce), plus the marker contract the admin
7
+ // `system-status` check reads back.
8
+ //
9
+ // (a) The block carries a single heartbeat entry — no email-fetch /
10
+ // email-auto-respond line. Those dispatchers act without an operator
11
+ // booking and stay unscheduled by the installer.
12
+ // (b) The entry never `mkdir`s an account log dir. The legacy line ran
13
+ // `mkdir -p data/accounts/<id>/logs` every 60s, tripping
14
+ // setup-account/seed-neo4j's stub-account-dirs guard on reinstall.
15
+ // Logs land under <persistDir>/logs, outside the seed-guard zone.
16
+ // (c) NEO4J_URI is pinned to bolt://localhost:<brand port>. Cron inherits
17
+ // no shell env, so an unpinned URI would default to 7687 and leak
18
+ // across co-resident brand installs.
19
+ // (d) The BEGIN/END markers equal `# BEGIN <PRODUCT_UPPER> CRONS` — the
20
+ // exact strings admin `system-status` greps to report registration.
21
+ // (e) Re-running the merge is idempotent: a re-install replaces the block,
22
+ // never stacks a second copy.
23
+ import test from "node:test";
24
+ import assert from "node:assert/strict";
25
+ import { cronBlockMarkers, buildHeartbeatCronBlock, stripCronBlock, mergeHeartbeatBlock, } from "../cron-registration.js";
26
+ const OPTS = {
27
+ productName: "maxy-code",
28
+ platformRoot: "/home/me/maxy-code/platform",
29
+ neo4jPort: 17687,
30
+ nodeBin: "/usr/bin/node",
31
+ logDir: "/home/me/.maxy-code/logs",
32
+ };
33
+ test("markers are # BEGIN/END <PRODUCT_UPPER> CRONS (admin system-status contract)", () => {
34
+ const { begin, end } = cronBlockMarkers("maxy-code");
35
+ assert.equal(begin, "# BEGIN MAXY-CODE CRONS");
36
+ assert.equal(end, "# END MAXY-CODE CRONS");
37
+ });
38
+ test("block has exactly one heartbeat entry, no email lines", () => {
39
+ const block = buildHeartbeatCronBlock(OPTS);
40
+ const entries = block.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#"));
41
+ assert.equal(entries.length, 1, "expected exactly one cron entry");
42
+ assert.match(entries[0], / # heartbeat$/, "entry must be tagged # heartbeat");
43
+ assert.doesNotMatch(block, /email-fetch/, "email-fetch must not be scheduled");
44
+ assert.doesNotMatch(block, /email-auto-respond/, "email-auto-respond must not be scheduled");
45
+ });
46
+ test("heartbeat entry never mkdirs an account log dir (Task 039 guard)", () => {
47
+ const block = buildHeartbeatCronBlock(OPTS);
48
+ assert.doesNotMatch(block, /mkdir/, "no mkdir in the cron line");
49
+ assert.doesNotMatch(block, /data\/accounts/, "must not touch the seed-guard account dir");
50
+ assert.match(block, />> \/home\/me\/\.maxy-code\/logs\/check-due-events\.log 2>&1/, "log must redirect to <persistDir>/logs");
51
+ });
52
+ test("NEO4J_URI is pinned to the brand bolt port, never defaulting 7687 (Task 571 guard)", () => {
53
+ const block = buildHeartbeatCronBlock(OPTS);
54
+ assert.match(block, /NEO4J_URI=bolt:\/\/localhost:17687 /, "NEO4J_URI must pin the brand port");
55
+ assert.doesNotMatch(block, /7687(?!.)/, "must not reference the default 7687 port");
56
+ });
57
+ test("entry runs check-due-events.js with PLATFORM_ROOT and no ACCOUNT_ID", () => {
58
+ const block = buildHeartbeatCronBlock(OPTS);
59
+ assert.match(block, /PLATFORM_ROOT=\/home\/me\/maxy-code\/platform /);
60
+ assert.match(block, /\/plugins\/scheduling\/mcp\/dist\/scripts\/check-due-events\.js/);
61
+ assert.doesNotMatch(block, /ACCOUNT_ID=/, "watcher scans all accounts itself; no per-account scoping");
62
+ });
63
+ test("entry fires every minute", () => {
64
+ const block = buildHeartbeatCronBlock(OPTS);
65
+ const entry = block.split("\n").find((l) => l.includes("check-due-events"));
66
+ assert.match(entry, /^\* \* \* \* \* /, "must be a once-a-minute schedule");
67
+ });
68
+ test("stripCronBlock removes a present block and counts its entries", () => {
69
+ const tab = `MAILTO=""\n${buildHeartbeatCronBlock(OPTS)}\n`;
70
+ const { cleaned, removedEntries, matched } = stripCronBlock(tab, "maxy-code");
71
+ assert.equal(matched, true);
72
+ assert.equal(removedEntries, 1);
73
+ assert.equal(cleaned, 'MAILTO=""');
74
+ });
75
+ test("stripCronBlock is a no-op signal when no block is present", () => {
76
+ const tab = `0 3 * * * /usr/bin/backup.sh\n`;
77
+ const { cleaned, removedEntries, matched } = stripCronBlock(tab, "maxy-code");
78
+ assert.equal(matched, false);
79
+ assert.equal(removedEntries, 0);
80
+ assert.equal(cleaned, "0 3 * * * /usr/bin/backup.sh");
81
+ });
82
+ test("stripCronBlock escapes the brand name (hyphen/dot safe)", () => {
83
+ const block = buildHeartbeatCronBlock({ ...OPTS, productName: "real.agent-code" });
84
+ const { matched, removedEntries } = stripCronBlock(block, "real.agent-code");
85
+ assert.equal(matched, true);
86
+ assert.equal(removedEntries, 1);
87
+ });
88
+ test("mergeHeartbeatBlock appends to an empty crontab", () => {
89
+ const block = buildHeartbeatCronBlock(OPTS);
90
+ const merged = mergeHeartbeatBlock("", block, "maxy-code");
91
+ assert.equal(merged, `${block}\n`);
92
+ });
93
+ test("mergeHeartbeatBlock replaces an existing block — idempotent re-install", () => {
94
+ const block = buildHeartbeatCronBlock(OPTS);
95
+ const once = mergeHeartbeatBlock(`0 3 * * * /usr/bin/backup.sh\n`, block, "maxy-code");
96
+ const twice = mergeHeartbeatBlock(once, block, "maxy-code");
97
+ assert.equal(once, twice, "re-applying the merge must not stack a second block");
98
+ const blockCount = (twice.match(/# BEGIN MAXY-CODE CRONS/g) || []).length;
99
+ assert.equal(blockCount, 1, "exactly one brand block after re-install");
100
+ });
@@ -0,0 +1,72 @@
1
+ // Scheduling-watcher heartbeat cron — pure string builders.
2
+ //
3
+ // The installer registers ONE install-level cron line that ticks
4
+ // `scheduling/mcp/dist/scripts/check-due-events.js` once a minute. The watcher
5
+ // scans every account itself (listLocalAccountIds → data/accounts/*/account.json)
6
+ // and only dispatches operator-created bookings whose time has come, so a
7
+ // per-minute tick creates no activity of its own.
8
+ //
9
+ // Narrowed from the block removed in Task 039 (commit 08e64c1f7), which also
10
+ // reintroduces neither regression that removal fixed:
11
+ // - No `mkdir` of an account log dir. Logs go to <persistDir>/logs, outside
12
+ // the seed-guard zone, so reinstall after `rm -rf ~/<brand>` never trips
13
+ // the stub-account-dirs guard.
14
+ // - NEO4J_URI pinned to the brand bolt port. Cron inherits no shell env, so
15
+ // an unpinned URI would default to 7687 and leak across co-resident brands
16
+ // (Task 571).
17
+ //
18
+ // The email dispatchers (email-fetch, email-auto-respond) act without an
19
+ // operator booking and stay unscheduled by the installer — they are NOT part
20
+ // of this block.
21
+ //
22
+ // I/O (read/write the user crontab) lives in the installer/uninstaller; this
23
+ // module is pure so the line shape and merge semantics are unit-testable.
24
+ /**
25
+ * BEGIN/END marker pair, byte-identical to the strings admin `system-status`
26
+ * greps (`# BEGIN ${productName.toUpperCase()} CRONS`).
27
+ */
28
+ export function cronBlockMarkers(productName) {
29
+ const upper = productName.toUpperCase();
30
+ return { begin: `# BEGIN ${upper} CRONS`, end: `# END ${upper} CRONS` };
31
+ }
32
+ /** The three-line cron block: BEGIN marker, the single heartbeat entry, END marker. */
33
+ export function buildHeartbeatCronBlock(opts) {
34
+ const { begin, end } = cronBlockMarkers(opts.productName);
35
+ const script = `${opts.platformRoot}/plugins/scheduling/mcp/dist/scripts/check-due-events.js`;
36
+ const log = `${opts.logDir}/check-due-events.log`;
37
+ const entry = `* * * * * PLATFORM_ROOT=${opts.platformRoot} ` +
38
+ `NEO4J_URI=bolt://localhost:${opts.neo4jPort} ` +
39
+ `${opts.nodeBin} ${script} >> ${log} 2>&1 # heartbeat`;
40
+ return [begin, entry, end].join("\n");
41
+ }
42
+ /** Escape a brand name for safe embedding in a RegExp (hyphens, dots). */
43
+ function escapeRegExp(s) {
44
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
45
+ }
46
+ /**
47
+ * Strip every brand cron block from a crontab string.
48
+ * `matched` is true when at least one block was present; `removedEntries`
49
+ * counts the non-comment, non-blank lines inside the removed block(s).
50
+ */
51
+ export function stripCronBlock(crontab, productName) {
52
+ const { begin, end } = cronBlockMarkers(productName);
53
+ const pattern = new RegExp(`${escapeRegExp(begin)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, "g");
54
+ const blocks = crontab.match(pattern) ?? [];
55
+ let removedEntries = 0;
56
+ for (const block of blocks) {
57
+ removedEntries += block
58
+ .split("\n")
59
+ .filter((line) => line.trim() && !line.trim().startsWith("#")).length;
60
+ }
61
+ const cleaned = crontab.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimEnd();
62
+ return { cleaned, removedEntries, matched: blocks.length > 0 };
63
+ }
64
+ /**
65
+ * Produce the next crontab: strip any existing brand block, then append the
66
+ * fresh block. Re-running with the same block is idempotent — a re-install
67
+ * replaces the block, never stacks a second copy.
68
+ */
69
+ export function mergeHeartbeatBlock(existing, block, productName) {
70
+ const { cleaned } = stripCronBlock(existing, productName);
71
+ return cleaned ? `${cleaned}\n${block}\n` : `${block}\n`;
72
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { seedBypassPermissionsSettings, assertBypassPermissionsSeed } from "./pe
6
6
  import { resolve, join, dirname } from "node:path";
7
7
  import { randomBytes } from "node:crypto";
8
8
  import { resolveInstallPortFromFs, buildMaxyUnitFile, buildClaudeSessionManagerUnitFile, buildClaudePtysSliceUnitFile, buildCloudflaredSliceUnitFile } from "./port-resolution.js";
9
+ import { buildHeartbeatCronBlock, mergeHeartbeatBlock } from "./cron-registration.js";
9
10
  import { validateTierFlag, validateEntitlementBase64, applyTierToAccountConfig, entitlementPath } from "./tier-flag.js";
10
11
  import { parseOsRelease, isUbuntuLike as isUbuntuLikePure, parseAptCacheCandidate, decideAptResolution, } from "./apt-resolve.js";
11
12
  import { findPeerBrandOnDefaultNeo4jPort } from "./peer-brand-detect.js";
@@ -2003,6 +2004,12 @@ function deployPayload() {
2003
2004
  // Persistent config lives at ~/{configDir}/ — survives rm -rf ~/{installDir}
2004
2005
  const persistentDir = resolve(process.env.HOME ?? "/root", BRAND.configDir);
2005
2006
  const persistentPasswordFile = join(persistentDir, ".neo4j-password");
2007
+ // Email credentials (platform/plugins/email/mcp/src/lib/credentials.ts) are
2008
+ // written only to <PLATFORM_ROOT>/config/email-credentials.json — i.e.
2009
+ // INSTALL_DIR/platform/config/, inside the wipe zone below. Unlike
2010
+ // .neo4j-password there is no persistent source of truth, so the live file is
2011
+ // the only copy: back it up before the wipe and restore it after deploy.
2012
+ const persistentEmailCredFile = join(persistentDir, "email-credentials.json");
2006
2013
  // users.json lives at <persistentDir>/users.json. Writers (paths.ts,
2007
2014
  // admin-add, set-pin, seed-neo4j.sh) target the persistent file directly —
2008
2015
  // see comment in platform/ui/app/lib/paths.ts.
@@ -2093,6 +2100,13 @@ function deployPayload() {
2093
2100
  shell(c.command, c.args, { sudo: true, bestEffort: true });
2094
2101
  }
2095
2102
  }
2103
+ // Save email credentials before the wipe — the live file under platform/ is
2104
+ // the only copy (see persistentEmailCredFile above). Restored after deploy.
2105
+ const liveEmailCredFile = join(INSTALL_DIR, "platform/config/email-credentials.json");
2106
+ if (existsSync(liveEmailCredFile)) {
2107
+ cpSync(liveEmailCredFile, persistentEmailCredFile);
2108
+ console.log(" Saved email credentials to persistent store.");
2109
+ }
2096
2110
  // Wipe deployment directories to prevent stale files.
2097
2111
  // data/ is NOT wiped — it contains user data (accounts, uploads) that must survive.
2098
2112
  for (const dir of ["platform", "server", "maxy", "premium-plugins", "docs", ".claude"]) {
@@ -2119,6 +2133,11 @@ function deployPayload() {
2119
2133
  cpSync(persistentPasswordFile, join(configDir, ".neo4j-password"));
2120
2134
  console.log(" Restored Neo4j password.");
2121
2135
  }
2136
+ if (existsSync(persistentEmailCredFile)) {
2137
+ cpSync(persistentEmailCredFile, join(configDir, "email-credentials.json"));
2138
+ chmodSync(join(configDir, "email-credentials.json"), 0o600);
2139
+ console.log(" Restored email credentials.");
2140
+ }
2122
2141
  // users.json is read directly from persistentDir by both
2123
2142
  // platform/ui/app/lib/paths.ts (USERS_FILE = MAXY_DIR/users.json) and the
2124
2143
  // admin MCP plugin (USERS_FILE = CONFIG_DIR/users.json). No copy into the
@@ -2963,9 +2982,10 @@ function registerSpecialistAgents() {
2963
2982
  // written by setup-account.sh during setupAccount(). One reader, one shape, one
2964
2983
  // source of truth.
2965
2984
  //
2966
- // retired the installer's cron registration `resolveInstallAccountId`
2967
- // kept its second consumer (installService) and is referenced again at print
2968
- // time for the post-install banner.
2985
+ // `resolveInstallAccountId` has two consumers: `installService` (stamps the
2986
+ // unit env) and the post-install banner. The restored heartbeat cron
2987
+ // (`installCrons` below) is NOT a third consumer — the scheduling watcher
2988
+ // scans every account itself, so the cron line carries no ACCOUNT_ID.
2969
2989
  // ---------------------------------------------------------------------------
2970
2990
  function resolveInstallAccountId() {
2971
2991
  // Managed-service model: prefer the single `role:"house"` account; fall back
@@ -2997,6 +3017,47 @@ function resolveInstallAccountId() {
2997
3017
  catch { /* directory unreadable */ }
2998
3018
  return "";
2999
3019
  }
3020
+ // ---------------------------------------------------------------------------
3021
+ // Cron registration — scheduling-watcher heartbeat
3022
+ //
3023
+ // Registers ONE install-level cron line that ticks check-due-events.js every
3024
+ // minute (see cron-registration.ts for the line shape and the two regression
3025
+ // guards). Idempotent across re-installs via the BEGIN/END markers. Linux-only
3026
+ // — darwin runs under launchd and registers no cron. The email dispatchers are
3027
+ // deliberately NOT registered here.
3028
+ //
3029
+ // The log dir <persistDir>/logs is created earlier in installServiceLinux
3030
+ // (alongside the .env write); the cron line never mkdir's it.
3031
+ // ---------------------------------------------------------------------------
3032
+ function installCrons() {
3033
+ if (!isLinux())
3034
+ return;
3035
+ const nodeBin = spawnSync("which", ["node"], { encoding: "utf-8" }).stdout.trim() || "/usr/bin/node";
3036
+ const platformRoot = join(INSTALL_DIR, "platform");
3037
+ const logDir = join(resolve(process.env.HOME ?? "/root", BRAND.configDir), "logs");
3038
+ const block = buildHeartbeatCronBlock({
3039
+ productName: BRAND.productName,
3040
+ platformRoot,
3041
+ neo4jPort: NEO4J_PORT,
3042
+ nodeBin,
3043
+ logDir,
3044
+ });
3045
+ const existing = spawnSync("crontab", ["-l"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
3046
+ const currentCrontab = existing.status === 0 ? existing.stdout : "";
3047
+ const newCrontab = mergeHeartbeatBlock(currentCrontab, block, BRAND.productName);
3048
+ const write = spawnSync("crontab", ["-"], {
3049
+ input: newCrontab,
3050
+ encoding: "utf-8",
3051
+ stdio: ["pipe", "pipe", "pipe"],
3052
+ });
3053
+ if (write.status === 0) {
3054
+ console.log(" Cron jobs: registered (heartbeat)");
3055
+ }
3056
+ else {
3057
+ console.error(` Cron jobs: failed to register — ${(write.stderr || "").trim()}`);
3058
+ logFile(` crontab write failed: ${write.stderr}`);
3059
+ }
3060
+ }
3000
3061
  // retired the ttyd/tmux/xterm admin terminal stack. Upgrades run
3001
3062
  // via the action runner — `systemd-run --user` transient units spawned by
3002
3063
  // POST /api/admin/actions/upgrade — whose lifetime is independent of
@@ -3828,6 +3889,10 @@ WantedBy=multi-user.target
3828
3889
  if (!webServerUp) {
3829
3890
  console.log(` Server may still be starting. Check http://${DEVICE_HOSTNAME}.local:${PORT} in a moment.`);
3830
3891
  }
3892
+ // Register the scheduling-watcher heartbeat cron — depends on the web server
3893
+ // being up (the dispatched MCP tools talk to the running platform), independent
3894
+ // of CDP. <persistDir>/logs was created above.
3895
+ installCrons();
3831
3896
  // Validate CDP: the programmatic Playwright MCP server connects to Chromium
3832
3897
  // via --cdp-endpoint http://127.0.0.1:9222. In virtual (VNC) mode, Chromium is
3833
3898
  // started by vnc.sh (ExecStartPre) before the web server — a failed probe here
package/dist/uninstall.js CHANGED
@@ -4,6 +4,7 @@ import { resolve, join, dirname } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { createInterface } from "node:readline";
6
6
  import { removeBrandStanza, hasAnyBrandStanza } from "./samba-provision.js";
7
+ import { stripCronBlock } from "./cron-registration.js";
7
8
  import { resolveDedicatedNeo4jTeardown } from "./neo4j-teardown.js";
8
9
  import { KNOWN_BRAND_HOSTNAMES } from "./known-brands.js";
9
10
  const HOME = homedir();
@@ -94,6 +95,35 @@ function commandExists(cmd) {
94
95
  }
95
96
  }
96
97
  // ---------------------------------------------------------------------------
98
+ // Cron block removal
99
+ //
100
+ // Strips the `# BEGIN <PRODUCT> CRONS` … `# END` block (the installer's
101
+ // scheduling-watcher heartbeat) from the user crontab. Idempotent: a no-op
102
+ // when the block is absent, leaving a clean crontab byte-identical. When the
103
+ // block was the only content, the crontab is dropped entirely with `crontab -r`
104
+ // so no empty tab is left behind. Linux-only — darwin registers no cron.
105
+ // ---------------------------------------------------------------------------
106
+ function removeCronBlock() {
107
+ if (!isLinux())
108
+ return;
109
+ if (!commandExists("crontab"))
110
+ return;
111
+ const existing = spawnSync("crontab", ["-l"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
112
+ const currentCrontab = existing.status === 0 ? existing.stdout : "";
113
+ const { cleaned, removedEntries, matched } = stripCronBlock(currentCrontab, BRAND.productName);
114
+ if (!matched) {
115
+ console.log(" Cron block: none present");
116
+ return;
117
+ }
118
+ if (cleaned.trim() === "") {
119
+ spawnSync("crontab", ["-r"], { stdio: ["pipe", "pipe", "pipe"] });
120
+ }
121
+ else {
122
+ spawnSync("crontab", ["-"], { input: `${cleaned}\n`, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
123
+ }
124
+ console.log(` Cron block: removed ${removedEntries} entries`);
125
+ }
126
+ // ---------------------------------------------------------------------------
97
127
  // Detection
98
128
  // ---------------------------------------------------------------------------
99
129
  export function isMaxyInstalled() {
@@ -949,6 +979,7 @@ export async function runUninstall(options) {
949
979
  const failures = [];
950
980
  const steps = [
951
981
  { name: "Stop services", fn: stopServices },
982
+ { name: "Remove cron block", fn: removeCronBlock },
952
983
  { name: "Delete Cloudflare tunnel", fn: deleteCloudflareTunnel },
953
984
  ...(options.exportPath
954
985
  ? [{ name: "Export data", fn: () => exportData(options.exportPath) }]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.367",
3
+ "version": "0.1.369",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "email",
3
- "description": "Email — dedicated email account. Retrieval: email-graph-query for stored email-thread history as :ConversationArchive {source:'email'} with :Section chunks (recall, search, 'threads about X', 'what did Y send?'); email-read / email-search for live IMAP inbox previews (with best-effort per-archive section hints from the graph); email-fetch-body for the COMPLETE decoded body of one message (by uid or message_id) when a preview is not enough. Ingestion (operator-initiated, human-in-the-loop): email-fetch lists new messages, email-ingest writes the operator-approved subset via the conversation-archive pipeline. Sending: email-send (new), email-reply (threaded — resolves the original via IMAP Message-ID search), email-draft (compose to the mailbox Drafts folder, unsent, for operator review; pass messageId for a threaded draft-reply), email-draft-edit (revise a stored draft, located by its Drafts UID or, when Gmail has renumbered it, its draft Message-ID), email-draft-send (dispatch a stored draft by Drafts UID, then remove it). Config: email-provider-info (pre-password host + app-password lookup from an address alone), email-setup, email-status, email-otp-extract.",
3
+ "description": "Email — dedicated email account. Retrieval: email-graph-query for stored email-thread history as :ConversationArchive {source:'email'} with :Section chunks (recall, search, 'threads about X', 'what did Y send?'); email-read / email-search for live IMAP inbox previews (with best-effort per-archive section hints from the graph); email-fetch-body for the COMPLETE decoded body of one message (by uid or message_id) when a preview is not enough. Ingestion (operator-initiated, human-in-the-loop): email-fetch lists new messages, email-ingest writes the operator-approved subset via the conversation-archive pipeline. Sending: email-send (new), email-reply (threaded — resolves the original via IMAP Message-ID search), email-draft (compose to the mailbox Drafts folder, unsent, for operator review; pass messageId for a threaded draft-reply), email-draft-edit (revise a stored draft, located by its Drafts UID or, when Gmail has renumbered it, its draft Message-ID), email-draft-send (dispatch a stored draft by Drafts UID, then remove it). Deleting: email-delete (move named UIDs from email-read/email-search to the mailbox Trash — recoverable, never an expunge). Config: email-provider-info (pre-password host + app-password lookup from an address alone), email-setup, email-status, email-otp-extract.",
4
4
  "version": "0.1.0",
5
5
  "author": {
6
6
  "name": "Rubytech LLC"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: email
3
- description: "Email — dedicated email account. Retrieval: email-graph-query for stored email-thread history as :ConversationArchive {source:'email'} with :Section chunks (recall, search, 'threads about X', 'what did Y send?'); email-read / email-search for live IMAP inbox previews (with best-effort per-archive section hints from the graph); email-fetch-body for the COMPLETE decoded body of one message (by uid or message_id) when a preview is not enough. Ingestion (operator-initiated, human-in-the-loop): email-fetch lists new messages, email-ingest writes the operator-approved subset via the conversation-archive pipeline. Sending: email-send (new), email-reply (threaded — resolves the original via IMAP Message-ID search), email-draft (compose to the mailbox Drafts folder, unsent, for operator review; pass messageId for a threaded draft-reply), email-draft-edit (revise a stored draft, located by its Drafts UID or, when Gmail has renumbered it, its draft Message-ID), email-draft-send (dispatch a stored draft by Drafts UID, then remove it). Config: email-provider-info (pre-password host + app-password lookup from an address alone), email-setup, email-status, email-otp-extract."
3
+ description: "Email — dedicated email account. Retrieval: email-graph-query for stored email-thread history as :ConversationArchive {source:'email'} with :Section chunks (recall, search, 'threads about X', 'what did Y send?'); email-read / email-search for live IMAP inbox previews (with best-effort per-archive section hints from the graph); email-fetch-body for the COMPLETE decoded body of one message (by uid or message_id) when a preview is not enough. Ingestion (operator-initiated, human-in-the-loop): email-fetch lists new messages, email-ingest writes the operator-approved subset via the conversation-archive pipeline. Sending: email-send (new), email-reply (threaded — resolves the original via IMAP Message-ID search), email-draft (compose to the mailbox Drafts folder, unsent, for operator review; pass messageId for a threaded draft-reply), email-draft-edit (revise a stored draft, located by its Drafts UID or, when Gmail has renumbered it, its draft Message-ID), email-draft-send (dispatch a stored draft by Drafts UID, then remove it). Deleting: email-delete (move named UIDs from email-read/email-search to the mailbox Trash — recoverable, never an expunge). Config: email-provider-info (pre-password host + app-password lookup from an address alone), email-setup, email-status, email-otp-extract."
4
4
  tools:
5
5
  - name: email-provider-info
6
6
  publicAllowlist: false
@@ -32,6 +32,9 @@ tools:
32
32
  - name: email-search
33
33
  publicAllowlist: false
34
34
  adminAllowlist: false
35
+ - name: email-delete
36
+ publicAllowlist: false
37
+ adminAllowlist: false
35
38
  - name: email-graph-query
36
39
  publicAllowlist: false
37
40
  adminAllowlist: false
@@ -78,6 +81,7 @@ Manages the agent's own dedicated email account — IMAP for reading, SMTP for s
78
81
  - **Setup:** `email-setup` — collect credentials in plain chat and connect IMAP/SMTP. Hosts are auto-inferred for known providers; supports alias addresses (`agentAddress`).
79
82
  - **Read inbox:** `email-read` — metadata only (UID, sender, subject, date), plus a best-effort `Archive`/`Archive-Section` hint when the IMAP message's `receivedAt` falls inside a stored `:ConversationArchive {source:'email'}` section, plus an `Attachments (N): filename (mime, size); …` footer when the message carries enclosures. Supports pagination (`before_uid`), folders (`inbox`/`sent`), filtering by sender/date/subject. Metadata is derived from IMAP `bodyStructure` — no bytes downloaded.
80
83
  - **Search inbox:** `email-search` — live IMAP search by sender, subject, body keyword, date range. Same per-archive hint and attachment footer as `email-read`.
84
+ - **Delete (move to Trash):** `email-delete` — move the messages named by `uids` (from a prior `email-read`/`email-search`, scoped to a source `folder` of `inbox` or `sent`) into the mailbox's Trash folder. **Recoverable, never an expunge** — the message leaves the inbox and permanent removal is left to the operator's own mail client or the provider's Trash auto-purge; this tool never permanently deletes. Trash is resolved per provider: the `\Trash` special-use folder if advertised, else the first existing of `Trash`, `[Gmail]/Trash`, `Deleted Items`, `Deleted Messages`. `Junk` is never treated as Trash. A mailbox with no resolvable Trash yields a typed error and moves nothing — there is no silent expunge fallback. A `uid` that is already gone is counted not-moved without failing the rest; the result reports `Moved N of M message(s) to <Trash>`. It deletes only the UIDs you name, never by search criteria.
81
85
  - **Read one whole message:** `email-fetch-body` — the COMPLETE decoded body of a single message addressed by `uid` (with optional `folder`) or `message_id` (resolved across inbox and sent). No 500-char preview cap. Downloads the full RFC822 source and decodes it with `mailparser`, so every MIME shape — flat text, single-part HTML, `multipart/alternative`, and nested `multipart/related`/`multipart/mixed` (Outlook's inline-image house style) — yields a body; the preview tools above intentionally stay preview-only. Use this when the operator wants to read or act on a message's actual content.
82
86
  - **Send:** `email-send` — new outbound email. Pass `cc`/`bcc` (each an array of email addresses) to copy or blind-copy recipients — CC appears in the message header, BCC recipients receive the message without appearing in any header. Pass `attachments` (absolute file paths under the account directory, ≤25 MB each) to attach files as SMTP attachments; an invalid path fails the send with no email dispatched. The success message names the CC recipients and reports the BCC count without naming them.
83
87
  - **Reply:** `email-reply` — threaded reply to an existing email by `messageId`. Resolves the original envelope via IMAP `SEARCH HEADER Message-ID` (INBOX then `\Sent`), so threading works for any message still present on the server even if the graph never ingested it. Supports `replyAll`, `cc`/`bcc`, and `attachments` (same path rules as `email-send`). Explicit `cc`/`bcc` are added in addition to whatever `replyAll` places in `To`, passed through as given (no overlap de-duplication against `To`).
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=email-delete.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-delete.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/email-delete.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,151 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ // Mock imapflow so moveToTrash's internal createClient() yields our spy client.
3
+ // `calls` records the wire sequence (lock, search, messageMove) so the move
4
+ // target, the not-moved accounting, and the resolve-before-lock contract can be
5
+ // asserted directly. `folders` is the client.list() result; `existingUids` is
6
+ // which UIDs the source folder actually holds (the rest are already gone).
7
+ const state = {
8
+ folders: [],
9
+ existingUids: [],
10
+ calls: { locked: null, searched: null, moves: [], released: false },
11
+ };
12
+ const mockClient = {
13
+ connect: vi.fn(async () => { }),
14
+ list: vi.fn(async () => state.folders),
15
+ getMailboxLock: vi.fn(async (path) => {
16
+ state.calls.locked = path;
17
+ return { release: () => { state.calls.released = true; } };
18
+ }),
19
+ search: vi.fn(async (query) => {
20
+ state.calls.searched = query;
21
+ const requested = String(query.uid).split(",").map(Number);
22
+ return requested.filter((u) => state.existingUids.includes(u));
23
+ }),
24
+ messageMove: vi.fn(async (range, dest, opts) => {
25
+ state.calls.moves.push({ range, dest, opts });
26
+ }),
27
+ logout: vi.fn(async () => { }),
28
+ close: vi.fn(() => { }),
29
+ };
30
+ vi.mock("imapflow", () => ({
31
+ ImapFlow: function ImapFlow() {
32
+ return mockClient;
33
+ },
34
+ }));
35
+ // Mock credentials so the tool resolves an account without touching Neo4j. The
36
+ // spy records that loadCredentials was called by accountId (the by-address read
37
+ // happens inside loadCredentials → readPassword), and supplies the config/password
38
+ // moveToTrash receives — the tool itself takes no password input. vi.hoisted keeps
39
+ // the spy and its config available to the (hoisted) vi.mock factory without a TDZ.
40
+ const { loadCredentials, credConfig } = vi.hoisted(() => {
41
+ const credConfig = {
42
+ email: "owner@x.com",
43
+ agentAddress: "owner@x.com",
44
+ imapHost: "imap.x.com",
45
+ imapPort: 993,
46
+ imapSecurity: "tls",
47
+ smtpHost: "smtp.x.com",
48
+ smtpPort: 587,
49
+ smtpSecurity: "starttls",
50
+ };
51
+ const loadCredentials = vi.fn(async (_accountId) => ({
52
+ credentials: { config: credConfig, password: "resolved-pw" },
53
+ }));
54
+ return { loadCredentials, credConfig };
55
+ });
56
+ vi.mock("../lib/credentials.js", () => ({ loadCredentials }));
57
+ import { moveToTrash, resolveTrashFolder } from "../lib/imap.js";
58
+ import { emailDelete, deleteLine } from "../tools/email-delete.js";
59
+ const config = {
60
+ email: "a@x.com",
61
+ agentAddress: "a@x.com",
62
+ imapHost: "imap.x.com",
63
+ imapPort: 993,
64
+ imapSecurity: "tls",
65
+ smtpHost: "smtp.x.com",
66
+ smtpPort: 587,
67
+ smtpSecurity: "starttls",
68
+ };
69
+ function reset(folders, existingUids) {
70
+ state.folders = folders;
71
+ state.existingUids = existingUids;
72
+ state.calls = { locked: null, searched: null, moves: [], released: false };
73
+ }
74
+ beforeEach(() => {
75
+ reset([], []);
76
+ });
77
+ describe("resolveTrashFolder", () => {
78
+ it("prefers the \\Trash special-use folder over a name match", async () => {
79
+ const folders = [{ path: "INBOX" }, { path: "Trash" }, { path: "Bin", specialUse: "\\Trash" }];
80
+ await expect(resolveTrashFolder({ list: async () => folders })).resolves.toBe("Bin");
81
+ });
82
+ it("falls back to a known Deleted-folder name when no special-use exists", async () => {
83
+ const folders = [{ path: "INBOX" }, { path: "Deleted Items" }];
84
+ await expect(resolveTrashFolder({ list: async () => folders })).resolves.toBe("Deleted Items");
85
+ });
86
+ it("throws a typed no-trash-folder error and never treats Junk as Trash", async () => {
87
+ const folders = [{ path: "INBOX" }, { path: "Junk" }];
88
+ await expect(resolveTrashFolder({ list: async () => folders })).rejects.toMatchObject({ code: "no-trash-folder" });
89
+ });
90
+ });
91
+ describe("moveToTrash", () => {
92
+ it("moves every requested UID that exists, targets the resolved Trash, and reports moved=requested", async () => {
93
+ reset([{ path: "INBOX" }, { path: "Trash", specialUse: "\\Trash" }], [10, 11, 12]);
94
+ const res = await moveToTrash(config, "pw", { folder: "inbox", uids: [10, 11, 12] });
95
+ expect(res).toEqual({ moved: 3, requested: 3, trash: "Trash" });
96
+ expect(state.calls.locked).toBe("INBOX");
97
+ expect(state.calls.moves).toHaveLength(1);
98
+ expect(state.calls.moves[0].range).toEqual([10, 11, 12]);
99
+ expect(state.calls.moves[0].dest).toBe("Trash");
100
+ expect(state.calls.moves[0].opts.uid).toBe(true);
101
+ expect(state.calls.released).toBe(true);
102
+ });
103
+ it("counts a vanished UID as not-moved and still moves the rest", async () => {
104
+ reset([{ path: "INBOX" }, { path: "Trash", specialUse: "\\Trash" }], [10, 12]); // 11 is gone
105
+ const res = await moveToTrash(config, "pw", { folder: "inbox", uids: [10, 11, 12] });
106
+ expect(res).toEqual({ moved: 2, requested: 3, trash: "Trash" });
107
+ expect(state.calls.moves[0].range).toEqual([10, 12]);
108
+ });
109
+ it("with no Trash folder errors, moves nothing, and never locks the source", async () => {
110
+ reset([{ path: "INBOX" }, { path: "Junk" }], [10]);
111
+ await expect(moveToTrash(config, "pw", { folder: "inbox", uids: [10] })).rejects.toMatchObject({ code: "no-trash-folder" });
112
+ expect(state.calls.moves).toHaveLength(0);
113
+ expect(state.calls.locked).toBeNull();
114
+ });
115
+ it("makes no messageMove call when none of the requested UIDs still exist", async () => {
116
+ reset([{ path: "INBOX" }, { path: "Trash", specialUse: "\\Trash" }], []);
117
+ const res = await moveToTrash(config, "pw", { folder: "inbox", uids: [10, 11] });
118
+ expect(res).toEqual({ moved: 0, requested: 2, trash: "Trash" });
119
+ expect(state.calls.moves).toHaveLength(0);
120
+ });
121
+ });
122
+ describe("emailDelete tool", () => {
123
+ it("resolves the credential by accountId (no password input) and moves via the resolved Trash", async () => {
124
+ reset([{ path: "INBOX" }, { path: "Trash", specialUse: "\\Trash" }], [10, 11]);
125
+ loadCredentials.mockClear();
126
+ const text = await emailDelete({ accountId: "acct-1", uids: [10, 11], folder: "inbox" });
127
+ expect(loadCredentials).toHaveBeenCalledWith("acct-1");
128
+ // The owner address came from the resolved config, not from any tool input.
129
+ expect(text).toBe("Moved 2 of 2 message(s) to Trash.");
130
+ expect(state.calls.moves[0].dest).toBe("Trash");
131
+ });
132
+ });
133
+ describe("deleteLine", () => {
134
+ it("carries the mailbox, counts, trash, and ok — never a password", () => {
135
+ const ok = deleteLine("owner@x.com", "inbox", 3, 2, "Trash", true);
136
+ expect(ok).toContain("op=delete");
137
+ expect(ok).toContain("email=owner@x.com");
138
+ expect(ok).toContain("folder=inbox");
139
+ expect(ok).toContain("requested=3");
140
+ expect(ok).toContain("moved=2");
141
+ expect(ok).toContain("trash=Trash");
142
+ expect(ok).toContain("ok=true");
143
+ expect(ok).not.toContain("resolved-pw");
144
+ });
145
+ it("records the typed reason and ok=false when no Trash resolves", () => {
146
+ const fail = deleteLine("owner@x.com", "inbox", 1, 0, null, false, "no-trash-folder");
147
+ expect(fail).toContain("ok=false");
148
+ expect(fail).toContain("reason=no-trash-folder");
149
+ });
150
+ });
151
+ //# sourceMappingURL=email-delete.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-delete.test.js","sourceRoot":"","sources":["../../src/__tests__/email-delete.test.ts"],"names":[],"mappings":"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"}
@@ -16,6 +16,7 @@ import { emailDraft } from "./tools/email-draft.js";
16
16
  import { emailDraftEdit } from "./tools/email-draft-edit.js";
17
17
  import { emailDraftSend } from "./tools/email-draft-send.js";
18
18
  import { emailSearch } from "./tools/email-search.js";
19
+ import { emailDelete } from "./tools/email-delete.js";
19
20
  import { emailOtpExtract } from "./tools/email-otp-extract.js";
20
21
  import { emailStatus } from "./tools/email-status.js";
21
22
  import { emailReply } from "./tools/email-reply.js";
@@ -378,6 +379,34 @@ eagerTool(server, "email-search", "Search messages by sender, subject, body, or
378
379
  }
379
380
  });
380
381
  // ===================================================================
382
+ // email-delete — move operator-named messages to the mailbox Trash
383
+ // ===================================================================
384
+ eagerTool(server, "email-delete", "Delete one or more messages by moving them to the mailbox's Trash folder. This is RECOVERABLE: the message leaves the inbox, and permanent removal is left to the operator's own mail client or the provider's Trash auto-purge — this tool never permanently deletes and never expunges. Target the exact messages by passing the uid values you already got from email-read or email-search, scoped to the folder they came from (inbox or sent). It never deletes by search criteria — only the UIDs you name. A uid that is already gone is counted not-moved without failing the rest. If the mailbox has no resolvable Trash folder, nothing is moved and the call errors.", {
385
+ uids: z
386
+ .array(z.coerce.number().int().positive())
387
+ .min(1)
388
+ .describe("The uid values of the messages to delete, taken from a prior email-read or email-search result. At least one."),
389
+ folder: z.enum(["inbox", "sent"]).optional().describe("The folder the messages are in (default: inbox)."),
390
+ }, async (params) => {
391
+ if (!accountId)
392
+ return refuseNoAccount("email-delete");
393
+ try {
394
+ const text = await emailDelete({ ...params, accountId });
395
+ return { content: [{ type: "text", text }] };
396
+ }
397
+ catch (err) {
398
+ return {
399
+ content: [
400
+ {
401
+ type: "text",
402
+ text: `Failed to delete email: ${err instanceof Error ? err.message : String(err)}`,
403
+ },
404
+ ],
405
+ isError: true,
406
+ };
407
+ }
408
+ });
409
+ // ===================================================================
381
410
  // email-otp-extract — poll for and extract OTP verification codes
382
411
  // ===================================================================
383
412
  eagerTool(server, "email-otp-extract", "Poll the agent's email inbox for a verification code (OTP) from a specific sender. Searches recent messages first, then polls at intervals until a matching email arrives or the timeout is reached. Used by other skills during service authentication.", {