@rubytech/create-maxy-code 0.1.439 → 0.1.441

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 (107) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +41 -12
  3. package/payload/platform/plugins/cloudflare/skills/calendar-site/SKILL.md +15 -5
  4. package/payload/platform/plugins/cloudflare/skills/calendar-site/template/functions/api/calendar/free-busy.ts +152 -0
  5. package/payload/platform/plugins/cloudflare/skills/calendar-site/template/public/index.html +6 -3
  6. package/payload/platform/plugins/cloudflare/skills/calendar-site/template/schema.sql +11 -0
  7. package/payload/platform/plugins/docs/references/admin-identity-gate.md +32 -17
  8. package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
  9. package/payload/platform/plugins/docs/references/calendar-booking.md +1 -1
  10. package/payload/platform/plugins/docs/references/graph.md +19 -3
  11. package/payload/platform/plugins/docs/references/internals.md +2 -2
  12. package/payload/platform/plugins/docs/references/outlook-guide.md +9 -4
  13. package/payload/platform/plugins/docs/references/troubleshooting.md +8 -0
  14. package/payload/platform/plugins/graph/.claude-plugin/plugin.json +19 -0
  15. package/payload/platform/plugins/graph/PLUGIN.md +10 -4
  16. package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
  17. package/payload/platform/plugins/outlook/PLUGIN.md +7 -3
  18. package/payload/platform/plugins/outlook/mcp/dist/__tests__/account-register.test.js +100 -10
  19. package/payload/platform/plugins/outlook/mcp/dist/__tests__/account-register.test.js.map +1 -1
  20. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-list.test.d.ts +2 -0
  21. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-list.test.d.ts.map +1 -0
  22. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-list.test.js +55 -0
  23. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-list.test.js.map +1 -0
  24. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-list.test.d.ts +2 -0
  25. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-list.test.d.ts.map +1 -0
  26. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-list.test.js +64 -0
  27. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-list.test.js.map +1 -0
  28. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-registry.test.d.ts +2 -0
  29. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-registry.test.d.ts.map +1 -0
  30. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-registry.test.js +193 -0
  31. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-registry.test.js.map +1 -0
  32. package/payload/platform/plugins/outlook/mcp/dist/__tests__/token-store.test.js +58 -1
  33. package/payload/platform/plugins/outlook/mcp/dist/__tests__/token-store.test.js.map +1 -1
  34. package/payload/platform/plugins/outlook/mcp/dist/auth/mailbox-registry.d.ts +81 -0
  35. package/payload/platform/plugins/outlook/mcp/dist/auth/mailbox-registry.d.ts.map +1 -0
  36. package/payload/platform/plugins/outlook/mcp/dist/auth/mailbox-registry.js +171 -0
  37. package/payload/platform/plugins/outlook/mcp/dist/auth/mailbox-registry.js.map +1 -0
  38. package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.d.ts +16 -1
  39. package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.d.ts.map +1 -1
  40. package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.js +25 -3
  41. package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.js.map +1 -1
  42. package/payload/platform/plugins/outlook/mcp/dist/index.js +119 -50
  43. package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
  44. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js +1 -1
  45. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js.map +1 -1
  46. package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.d.ts +8 -4
  47. package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.d.ts.map +1 -1
  48. package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.js +22 -3
  49. package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.js.map +1 -1
  50. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.d.ts +17 -0
  51. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.d.ts.map +1 -1
  52. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.js +84 -21
  53. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.js.map +1 -1
  54. package/payload/platform/plugins/outlook/mcp/dist/tools/mailbox-list.d.ts +26 -0
  55. package/payload/platform/plugins/outlook/mcp/dist/tools/mailbox-list.d.ts.map +1 -0
  56. package/payload/platform/plugins/outlook/mcp/dist/tools/mailbox-list.js +28 -0
  57. package/payload/platform/plugins/outlook/mcp/dist/tools/mailbox-list.js.map +1 -0
  58. package/payload/platform/plugins/outlook/references/auth.md +36 -8
  59. package/payload/platform/plugins/outlook/references/graph-surfaces.md +15 -1
  60. package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.d.ts +27 -0
  61. package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.d.ts.map +1 -0
  62. package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js +241 -0
  63. package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js.map +1 -0
  64. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +100 -0
  65. package/payload/platform/scripts/lib/account-schema-owned-dirs.py +207 -0
  66. package/payload/platform/scripts/lib/account-schema-owned-dirs.sh +33 -0
  67. package/payload/platform/scripts/lib/provision-account-dir.sh +7 -0
  68. package/payload/platform/scripts/setup-account.sh +10 -0
  69. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  70. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  71. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  72. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  73. package/payload/platform/services/claude-session-manager/dist/http-server.js +6 -4
  74. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  75. package/payload/platform/services/claude-session-manager/dist/tool-registration-drift.d.ts +7 -6
  76. package/payload/platform/services/claude-session-manager/dist/tool-registration-drift.d.ts.map +1 -1
  77. package/payload/platform/services/claude-session-manager/dist/tool-registration-drift.js +56 -7
  78. package/payload/platform/services/claude-session-manager/dist/tool-registration-drift.js.map +1 -1
  79. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  80. package/payload/server/{chunk-UGPQCVIW.js → chunk-WC4WJR6X.js} +0 -6
  81. package/payload/server/maxy-edge.js +1 -1
  82. package/payload/server/public/assets/{AdminLoginScreens-DeR71H29.js → AdminLoginScreens-CukwQufW.js} +1 -1
  83. package/payload/server/public/assets/AdminShell-BwEBH6tT.js +2 -0
  84. package/payload/server/public/assets/{admin-C15h-NL0.js → admin-CdFRCUUZ.js} +1 -1
  85. package/payload/server/public/assets/{browser-B7xQLIRK.js → browser-BWR59IkB.js} +1 -1
  86. package/payload/server/public/assets/{calendar-A-vGm6pL.js → calendar-kuTcxX1t.js} +1 -1
  87. package/payload/server/public/assets/chat-ejnq2rck.js +1 -0
  88. package/payload/server/public/assets/data-dZbOyLdT.js +1 -0
  89. package/payload/server/public/assets/{graph-D0orIRAh.js → graph-CboWM2_-.js} +2 -2
  90. package/payload/server/public/assets/{operator-DZzuotDB.js → operator-oSaB1mya.js} +1 -1
  91. package/payload/server/public/assets/{page-Ck_pLhAk.js → page-DEkI-Gl1.js} +1 -1
  92. package/payload/server/public/assets/{page-3g0A19Rv.js → page-DMeJHVfA.js} +5 -5
  93. package/payload/server/public/assets/{public-WFUQw9AD.js → public-C54tl6V4.js} +1 -1
  94. package/payload/server/public/assets/{useCopyFeedback-CSk9KjhY.js → useCopyFeedback-DHoYHiK9.js} +1 -1
  95. package/payload/server/public/assets/{useVoiceRecorder-DCyJQkFK.js → useVoiceRecorder-b7HzTFs0.js} +1 -1
  96. package/payload/server/public/browser.html +2 -2
  97. package/payload/server/public/calendar.html +2 -2
  98. package/payload/server/public/chat.html +5 -5
  99. package/payload/server/public/data.html +3 -3
  100. package/payload/server/public/graph.html +3 -3
  101. package/payload/server/public/index.html +5 -5
  102. package/payload/server/public/operator.html +6 -6
  103. package/payload/server/public/public.html +5 -5
  104. package/payload/server/server.js +255 -112
  105. package/payload/server/public/assets/AdminShell-DlAVQUda.js +0 -2
  106. package/payload/server/public/assets/chat-DAae6Z51.js +0 -1
  107. package/payload/server/public/assets/data-BNWpKx1d.js +0 -1
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Dispatcher script: publish-availability
3
+ *
4
+ * Self-contained, like reconcile-bookings. Publishes the install account's
5
+ * computed free/busy into its Cloudflare D1 `availability_snapshot` table so the
6
+ * public booking page's edge Function keeps serving open times while the device
7
+ * is offline. Runs while the device is ONLINE; when the device is off it simply
8
+ * does not run and the edge keeps serving the last snapshot until it returns.
9
+ *
10
+ * It reads the device's own free/busy from the always-running local server
11
+ * (127.0.0.1:$MAXY_UI_INTERNAL_PORT/api/calendar/free-busy — a gate-exempt public
12
+ * path), so it needs no Neo4j and no tunnel/DNS. The account whose free/busy the
13
+ * endpoint serves is the one the server resolved and hands in via
14
+ * PUBLISH_ACCOUNT_DIR / PUBLISH_ACCOUNT_ID.
15
+ *
16
+ * If the local endpoint is unreachable it makes NO D1 write (result=skipped-
17
+ * unreachable), leaving the last edge snapshot intact. A PID lock prevents
18
+ * overlapping runs. Each run records the outcome to a local last-publish.json so
19
+ * the server's audit tick can report snapshot age independent of this job.
20
+ *
21
+ * Observability — one [calendar-availability] op=publish line per run.
22
+ *
23
+ * PLATFORM_ROOT=... MAXY_UI_INTERNAL_PORT=... PUBLISH_ACCOUNT_DIR=... \
24
+ * PUBLISH_ACCOUNT_ID=... node dist/scripts/publish-availability.js
25
+ */
26
+ import { spawnSync } from "node:child_process";
27
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
28
+ import { resolve, join, basename } from "node:path";
29
+ import { homedir, tmpdir } from "node:os";
30
+ function resolveConfigDir() {
31
+ const platformRoot = process.env.PLATFORM_ROOT;
32
+ if (!platformRoot)
33
+ throw new Error("PLATFORM_ROOT environment variable is required");
34
+ const brandPath = resolve(platformRoot, "config", "brand.json");
35
+ if (!existsSync(brandPath))
36
+ throw new Error(`brand.json not found at ${brandPath}`);
37
+ const brand = JSON.parse(readFileSync(brandPath, "utf-8"));
38
+ if (!brand.configDir)
39
+ throw new Error(`brand.json at ${brandPath} is missing configDir`);
40
+ return brand.configDir;
41
+ }
42
+ const CONFIG_DIR = resolve(homedir(), resolveConfigDir());
43
+ const LOCK_FILE = resolve(CONFIG_DIR, "publish-availability.lock");
44
+ function acquireLock() {
45
+ mkdirSync(CONFIG_DIR, { recursive: true });
46
+ if (existsSync(LOCK_FILE)) {
47
+ const storedPid = readFileSync(LOCK_FILE, "utf-8").trim();
48
+ try {
49
+ process.kill(Number(storedPid), 0);
50
+ return false; // a run is in progress
51
+ }
52
+ catch {
53
+ unlinkSync(LOCK_FILE); // stale
54
+ }
55
+ }
56
+ writeFileSync(LOCK_FILE, String(process.pid));
57
+ return true;
58
+ }
59
+ function releaseLock() {
60
+ try {
61
+ if (existsSync(LOCK_FILE) && readFileSync(LOCK_FILE, "utf-8").trim() === String(process.pid)) {
62
+ unlinkSync(LOCK_FILE);
63
+ }
64
+ }
65
+ catch {
66
+ /* best effort */
67
+ }
68
+ }
69
+ /** Read `KEY=value` pairs from a secrets env file without polluting process.env. */
70
+ function readEnvFile(path) {
71
+ const out = {};
72
+ if (!existsSync(path))
73
+ return out;
74
+ for (const line of readFileSync(path, "utf-8").split("\n")) {
75
+ const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
76
+ if (m)
77
+ out[m[1]] = m[2];
78
+ }
79
+ return out;
80
+ }
81
+ /**
82
+ * Record the outcome for the server's audit tick. lastSuccessAt advances only on
83
+ * a successful D1 write, so a run of failures leaves it stale and the server
84
+ * reports a climbing snapshotAgeMs.
85
+ */
86
+ function writeLastPublish(accountDir, result, slots, lastSuccessAt) {
87
+ const stateDir = join(accountDir, "state", "booking-availability");
88
+ try {
89
+ mkdirSync(stateDir, { recursive: true });
90
+ writeFileSync(join(stateDir, "last-publish.json"), JSON.stringify({ result, slots, lastSuccessAt, at: new Date().toISOString() }));
91
+ }
92
+ catch {
93
+ /* best effort — the log line is the primary signal */
94
+ }
95
+ }
96
+ function readPriorSuccessAt(accountDir) {
97
+ const path = join(accountDir, "state", "booking-availability", "last-publish.json");
98
+ if (!existsSync(path))
99
+ return null;
100
+ try {
101
+ const prior = JSON.parse(readFileSync(path, "utf-8"));
102
+ return prior.lastSuccessAt ?? null;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ /** UTC midnight of `now`, and `days` after it, as `YYYY-MM-DDT00:00:00Z`. */
109
+ function utcMidnight(now, addDays) {
110
+ const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + addDays));
111
+ return d.toISOString().replace(/\.\d{3}Z$/, "Z");
112
+ }
113
+ async function main() {
114
+ if (!acquireLock()) {
115
+ console.error("[calendar-availability] op=publish result=skipped reason=lock-held");
116
+ return;
117
+ }
118
+ try {
119
+ const accountDir = process.env.PUBLISH_ACCOUNT_DIR;
120
+ if (!accountDir) {
121
+ console.error("[calendar-availability] op=publish result=skipped reason=no-account");
122
+ return;
123
+ }
124
+ const accountId = process.env.PUBLISH_ACCOUNT_ID ?? basename(accountDir);
125
+ const availPath = join(accountDir, "calendar-availability.json");
126
+ if (!existsSync(availPath)) {
127
+ // No booking site for this account — nothing to publish. Not an error.
128
+ return;
129
+ }
130
+ let avail;
131
+ try {
132
+ avail = JSON.parse(readFileSync(availPath, "utf-8"));
133
+ }
134
+ catch {
135
+ console.error(`[calendar-availability] op=publish accountId=${accountId} result=error reason=bad-availability-json`);
136
+ writeLastPublish(accountDir, "error", null, readPriorSuccessAt(accountDir));
137
+ return;
138
+ }
139
+ const dbName = avail.bookingDbName;
140
+ if (!dbName) {
141
+ console.error(`[calendar-availability] op=publish accountId=${accountId} result=skipped reason=no-booking-db`);
142
+ return;
143
+ }
144
+ const secrets = readEnvFile(join(accountDir, "secrets", "cloudflare.env"));
145
+ const token = secrets.CF_PAGES_D1_TOKEN;
146
+ if (!token) {
147
+ console.error(`[calendar-availability] op=publish accountId=${accountId} result=skipped reason=no-pages-d1-token`);
148
+ return;
149
+ }
150
+ const port = process.env.MAXY_UI_INTERNAL_PORT;
151
+ if (!port) {
152
+ console.error(`[calendar-availability] op=publish accountId=${accountId} result=error reason=no-internal-port`);
153
+ writeLastPublish(accountDir, "error", null, readPriorSuccessAt(accountDir));
154
+ return;
155
+ }
156
+ // Fetch the device's own computed free/busy over a horizon that covers the
157
+ // page's own (operator-set) window, +1 day of margin for the local-midnight
158
+ // boundary the page renders in.
159
+ const daysAhead = typeof avail.daysAhead === "number" && avail.daysAhead > 0 ? Math.floor(avail.daysAhead) : 14;
160
+ const now = new Date();
161
+ const from = utcMidnight(now, 0);
162
+ const to = utcMidnight(now, daysAhead + 1);
163
+ const nowIso = now.toISOString();
164
+ const url = `http://127.0.0.1:${port}/api/calendar/free-busy?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`;
165
+ let payload;
166
+ try {
167
+ // Bound the read so a wedged server cannot hold the PID lock.
168
+ const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
169
+ if (!res.ok)
170
+ throw new Error(`HTTP ${res.status}`);
171
+ payload = await res.text();
172
+ }
173
+ catch (err) {
174
+ // Device/local endpoint unreachable — leave the last edge snapshot intact.
175
+ console.error(`[calendar-availability] op=publish accountId=${accountId} result=skipped-unreachable err="${err.message}"`);
176
+ writeLastPublish(accountDir, "skipped-unreachable", null, readPriorSuccessAt(accountDir));
177
+ return;
178
+ }
179
+ // Must be JSON carrying a slots array, or we would poison the snapshot.
180
+ let slotCount;
181
+ try {
182
+ const parsed = JSON.parse(payload);
183
+ if (!Array.isArray(parsed.slots))
184
+ throw new Error("no slots array");
185
+ slotCount = parsed.slots.length;
186
+ }
187
+ catch (err) {
188
+ console.error(`[calendar-availability] op=publish accountId=${accountId} result=skipped reason=bad-payload err="${err.message}"`);
189
+ writeLastPublish(accountDir, "skipped", null, readPriorSuccessAt(accountDir));
190
+ return;
191
+ }
192
+ // Upsert via a temp SQL file — robust for a large payload where a --command
193
+ // argv would be fragile. Single quotes are escaped for the SQL literal. The
194
+ // CREATE first makes this self-healing: a booking site provisioned before
195
+ // this feature (its D1 has `bookings` but no `availability_snapshot`) gets
196
+ // the table here on the first publish, without waiting for a skill re-run.
197
+ const esc = payload.replace(/'/g, "''");
198
+ const sql = `CREATE TABLE IF NOT EXISTS availability_snapshot (
199
+ id INTEGER PRIMARY KEY CHECK (id = 1),
200
+ payload TEXT,
201
+ updatedAt TEXT
202
+ );
203
+ INSERT INTO availability_snapshot (id, payload, updatedAt)
204
+ VALUES (1, '${esc}', '${nowIso}')
205
+ ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, updatedAt=excluded.updatedAt;`;
206
+ const workDir = mkdtempSync(join(tmpdir(), "publish-availability-"));
207
+ const sqlFile = join(workDir, "upsert.sql");
208
+ try {
209
+ writeFileSync(sqlFile, sql);
210
+ const wranglerEnv = {
211
+ CLOUDFLARE_API_TOKEN: token,
212
+ ...(secrets.CLOUDFLARE_ACCOUNT_ID ? { CLOUDFLARE_ACCOUNT_ID: secrets.CLOUDFLARE_ACCOUNT_ID } : {}),
213
+ };
214
+ const proc = spawnSync("npx", ["wrangler", "d1", "execute", dbName, "--remote", "--file", sqlFile], {
215
+ env: { ...process.env, ...wranglerEnv },
216
+ encoding: "utf-8",
217
+ maxBuffer: 16 * 1024 * 1024,
218
+ timeout: 60_000,
219
+ });
220
+ if (proc.status !== 0) {
221
+ console.error(`[calendar-availability] op=publish accountId=${accountId} result=error reason=d1-write-failed status=${proc.status}${proc.signal ? ` signal=${proc.signal}` : ""}`);
222
+ writeLastPublish(accountDir, "error", slotCount, readPriorSuccessAt(accountDir));
223
+ return;
224
+ }
225
+ }
226
+ finally {
227
+ rmSync(workDir, { recursive: true, force: true });
228
+ }
229
+ console.error(`[calendar-availability] op=publish accountId=${accountId} slots=${slotCount} result=success`);
230
+ writeLastPublish(accountDir, "success", slotCount, nowIso);
231
+ }
232
+ finally {
233
+ releaseLock();
234
+ }
235
+ }
236
+ main().catch((err) => {
237
+ console.error(`[calendar-availability] op=publish result=error reason=fatal err="${err instanceof Error ? err.message : String(err)}"`);
238
+ releaseLock();
239
+ process.exit(1);
240
+ });
241
+ //# sourceMappingURL=publish-availability.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publish-availability.js","sourceRoot":"","sources":["../../src/scripts/publish-availability.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ # Tests the plugin-declared account-owned-dir merge/reconcile lib: a brand that
3
+ # ships a plugin declaring an owned top-level dir gets it unioned into the
4
+ # account SCHEMA.md allowed-top-level set; a brand without the plugin does not;
5
+ # the merge is idempotent; the reconcile check reports present before/after.
6
+ set -uo pipefail
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ PLAT_SCRIPTS="$SCRIPT_DIR/.."
9
+ REAL_PROJECT_DIR="$(cd "$PLAT_SCRIPTS/.." && pwd)"
10
+ REAL_TEMPLATE="$REAL_PROJECT_DIR/templates/account-schema/SCHEMA.md"
11
+ REAL_PREMIUM="$(cd "$REAL_PROJECT_DIR/.." && pwd)/premium-plugins"
12
+ PASS=0; FAIL=0; FAILED=()
13
+ assert_grep() { if printf '%s' "$2" | grep -qF "$1"; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$3: expected to find [$1]"); fi; }
14
+ assert_nogrep() { if printf '%s' "$2" | grep -qF "$1"; then FAIL=$((FAIL+1)); FAILED+=("$3: expected NOT to find [$1]"); else PASS=$((PASS+1)); fi; }
15
+ assert_eq() { if [ "$1" = "$2" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$3: expected [$2] got [$1]"); fi; }
16
+
17
+ . "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.sh"
18
+
19
+ # Build a fake platform tree at $T/platform whose premium sibling is a symlink to
20
+ # the real premium-plugins (so the real sitedesk-job PLUGIN.md is exercised).
21
+ make_tree() { # $1=ships (json array literal)
22
+ local T; T="$(mktemp -d)"
23
+ mkdir -p "$T/platform/plugins" "$T/platform/config" "$T/platform/templates/account-schema"
24
+ ln -s "$REAL_PREMIUM" "$T/premium-plugins"
25
+ cp "$REAL_TEMPLATE" "$T/platform/templates/account-schema/SCHEMA.md"
26
+ printf '{"installDir":"test-brand","shipsPremiumBundles":%s}\n' "$1" > "$T/platform/config/brand.json"
27
+ echo "$T"
28
+ }
29
+ make_account() { # $1=tree
30
+ local A; A="$(mktemp -d)"
31
+ cp "$1/platform/templates/account-schema/SCHEMA.md" "$A/SCHEMA.md"
32
+ printf '{"accountId":"x","role":"house"}\n' > "$A/account.json"
33
+ echo "$A"
34
+ }
35
+
36
+ # --- sitedesk-shipping brand: quoting added ---
37
+ T_SD="$(make_tree '["sitedesk"]')"; A_SD="$(make_account "$T_SD")"
38
+ export PROJECT_DIR="$T_SD/platform"
39
+ OUT="$(merge_owned_dirs_into_schema "$A_SD")"
40
+ assert_grep "op=seed-owned-dirs" "$OUT" "sd-seed-log"
41
+ assert_grep "added=quoting" "$OUT" "sd-added-quoting"
42
+ ALLOWED="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_SD/SCHEMA.md")"
43
+ assert_grep "quoting" "$ALLOWED" "sd-allowlist-has-quoting"
44
+ assert_grep "Plugin-owned top-level workspaces" "$(cat "$A_SD/SCHEMA.md")" "sd-descriptive-section"
45
+ assert_grep "plugin-owned-dirs:start" "$(cat "$A_SD/SCHEMA.md")" "sd-marker"
46
+
47
+ # --- idempotency: second merge is byte-identical ---
48
+ BEFORE="$(shasum "$A_SD/SCHEMA.md" | awk '{print $1}')"
49
+ OUT2="$(merge_owned_dirs_into_schema "$A_SD")"
50
+ AFTER="$(shasum "$A_SD/SCHEMA.md" | awk '{print $1}')"
51
+ assert_eq "$AFTER" "$BEFORE" "idempotent-second-merge"
52
+ assert_grep "added=none" "$OUT2" "sd-second-added-none"
53
+
54
+ # --- non-sitedesk brand: no quoting ---
55
+ T_MX="$(make_tree '[]')"; A_MX="$(make_account "$T_MX")"
56
+ export PROJECT_DIR="$T_MX/platform"
57
+ merge_owned_dirs_into_schema "$A_MX" >/dev/null
58
+ ALLOWED_MX="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_MX/SCHEMA.md")"
59
+ assert_nogrep "quoting" "$ALLOWED_MX" "mx-no-quoting"
60
+ assert_nogrep "Plugin-owned top-level workspaces" "$(cat "$A_MX/SCHEMA.md")" "mx-no-section"
61
+
62
+ # --- reconcile: present=false on bare schema, present=true after merge ---
63
+ A_RC="$(make_account "$T_SD")"
64
+ export PROJECT_DIR="$T_SD/platform"
65
+ RC_BEFORE="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_RC")"
66
+ assert_grep "ownedDir=quoting present=false" "$RC_BEFORE" "rc-present-false"
67
+ merge_owned_dirs_into_schema "$A_RC" >/dev/null
68
+ RC_AFTER="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_RC")"
69
+ assert_grep "ownedDir=quoting present=true" "$RC_AFTER" "rc-present-true"
70
+
71
+ # --- sweep: backfills every account with an account.json ---
72
+ ACCTS="$(mktemp -d)"; mkdir -p "$ACCTS/a1"
73
+ cp "$T_SD/platform/templates/account-schema/SCHEMA.md" "$ACCTS/a1/SCHEMA.md"
74
+ printf '{"accountId":"a1","role":"client"}\n' > "$ACCTS/a1/account.json"
75
+ SWEEP="$(reconcile_all_accounts_owned_dirs "$ACCTS" 2>&1)"
76
+ assert_grep "op=reconcile" "$SWEEP" "sweep-reconcile-log"
77
+ ALLOWED_A1="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCTS/a1/SCHEMA.md")"
78
+ assert_grep "quoting" "$ALLOWED_A1" "sweep-backfilled-a1"
79
+
80
+ # --- shape-b: a bundle-root PLUGIN.md declaration is resolved too ---
81
+ # A premium bundle may declare an owned dir in its root PLUGIN.md (depth 1),
82
+ # not only in a sub-plugin manifest (depth 2). Build a fully synthetic tree.
83
+ TB="$(mktemp -d)"
84
+ mkdir -p "$TB/platform/plugins" "$TB/platform/config" "$TB/platform/templates/account-schema"
85
+ mkdir -p "$TB/premium-plugins/acme/plugins/acme-sub"
86
+ cp "$REAL_TEMPLATE" "$TB/platform/templates/account-schema/SCHEMA.md"
87
+ printf '{"installDir":"acme-brand","shipsPremiumBundles":["acme"]}\n' > "$TB/platform/config/brand.json"
88
+ printf -- '---\nname: acme\naccount-owned-dirs: [{"dir": "ledger", "description": "Acme ledger."}]\n---\n' > "$TB/premium-plugins/acme/PLUGIN.md"
89
+ printf -- '---\nname: acme-sub\n---\n' > "$TB/premium-plugins/acme/plugins/acme-sub/PLUGIN.md"
90
+ A_TB="$(mktemp -d)"; cp "$TB/platform/templates/account-schema/SCHEMA.md" "$A_TB/SCHEMA.md"
91
+ export PROJECT_DIR="$TB/platform"
92
+ RES_TB="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" resolve)"
93
+ assert_grep "ledger" "$RES_TB" "shapeb-resolve-ledger"
94
+ assert_grep "\"plugin\": \"acme\"" "$RES_TB" "shapeb-owner-is-bundle"
95
+ merge_owned_dirs_into_schema "$A_TB" >/dev/null
96
+ ALLOWED_TB="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_TB/SCHEMA.md")"
97
+ assert_grep "ledger" "$ALLOWED_TB" "shapeb-allowlist-has-ledger"
98
+
99
+ echo "PASS=$PASS FAIL=$FAIL"
100
+ if [ "$FAIL" -ne 0 ]; then printf '%s\n' "${FAILED[@]}"; exit 1; fi
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve plugin-declared account-owned top-level dirs for the brand and merge
3
+ them into an account's SCHEMA.md.
4
+
5
+ A plugin declares owned top-level account dirs in its PLUGIN.md frontmatter:
6
+
7
+ account-owned-dirs: [{"dir": "quoting", "description": "..."}]
8
+
9
+ The owned-dir set for a brand is the union declared by every platform plugin
10
+ ($PROJECT_DIR/plugins/*/PLUGIN.md) and every premium sub-plugin of a bundle the
11
+ brand ships ($PREMIUM_ROOT/<bundle>/plugins/*/PLUGIN.md, gated by brand.json
12
+ shipsPremiumBundles). Reads PROJECT_DIR from the environment.
13
+ """
14
+ import json, os, re, sys
15
+
16
+ MARK_START = "<!-- plugin-owned-dirs:start -->"
17
+ MARK_END = "<!-- plugin-owned-dirs:end -->"
18
+
19
+
20
+ def _project_dir():
21
+ pd = os.environ.get("PROJECT_DIR", "")
22
+ if not pd:
23
+ sys.stderr.write("account-schema-owned-dirs: PROJECT_DIR unset\n")
24
+ sys.exit(1)
25
+ return os.path.normpath(pd)
26
+
27
+
28
+ def _read_brand(project_dir):
29
+ path = os.path.join(project_dir, "config", "brand.json")
30
+ if not os.path.isfile(path):
31
+ return "unknown", []
32
+ try:
33
+ with open(path) as f:
34
+ b = json.load(f)
35
+ return (b.get("installDir") or "unknown"), list(b.get("shipsPremiumBundles") or [])
36
+ except Exception:
37
+ return "unknown", []
38
+
39
+
40
+ def _parse_plugin_md(path):
41
+ """Return (name, [ {dir, description} ]) from a PLUGIN.md, or (None, [])."""
42
+ try:
43
+ with open(path) as f:
44
+ text = f.read()
45
+ except Exception:
46
+ return None, []
47
+ m = re.match(r"^---\n(.*?)\n---", text, re.DOTALL)
48
+ if not m:
49
+ return None, []
50
+ fm = m.group(1)
51
+ name = None
52
+ nm = re.search(r"^name:\s*(.+)$", fm, re.MULTILINE)
53
+ if nm:
54
+ name = nm.group(1).strip().strip('"').strip("'")
55
+ owned = []
56
+ om = re.search(r"^account-owned-dirs:\s*(.+)$", fm, re.MULTILINE)
57
+ if om:
58
+ try:
59
+ raw = json.loads(om.group(1).strip())
60
+ for e in raw:
61
+ d = (e.get("dir") or "").strip()
62
+ if d:
63
+ owned.append({"dir": d, "description": (e.get("description") or "").strip()})
64
+ except Exception:
65
+ pass
66
+ return name, owned
67
+
68
+
69
+ def _plugin_md_paths(project_dir):
70
+ paths = []
71
+ plugins_root = os.path.join(project_dir, "plugins")
72
+ if os.path.isdir(plugins_root):
73
+ for entry in sorted(os.listdir(plugins_root)):
74
+ p = os.path.join(plugins_root, entry, "PLUGIN.md")
75
+ if os.path.isfile(p):
76
+ paths.append(p)
77
+ _brand, ships = _read_brand(project_dir)
78
+ premium_root = os.path.join(os.path.dirname(project_dir), "premium-plugins")
79
+ for bundle in ships:
80
+ bundle_dir = os.path.join(premium_root, bundle)
81
+ # Bundle-root manifest (shape b): premium-plugins/<bundle>/PLUGIN.md.
82
+ root_md = os.path.join(bundle_dir, "PLUGIN.md")
83
+ if os.path.isfile(root_md):
84
+ paths.append(root_md)
85
+ # Sub-plugin manifests (shape a): premium-plugins/<bundle>/plugins/*/PLUGIN.md.
86
+ subs = os.path.join(bundle_dir, "plugins")
87
+ if not os.path.isdir(subs):
88
+ continue
89
+ for entry in sorted(os.listdir(subs)):
90
+ p = os.path.join(subs, entry, "PLUGIN.md")
91
+ if os.path.isfile(p):
92
+ paths.append(p)
93
+ return paths
94
+
95
+
96
+ def resolve(project_dir):
97
+ """Union of owned-dir declarations. First declarer of a dir wins its owner
98
+ label + description."""
99
+ seen = {}
100
+ order = []
101
+ for path in _plugin_md_paths(project_dir):
102
+ name, owned = _parse_plugin_md(path)
103
+ for e in owned:
104
+ if e["dir"] in seen:
105
+ continue
106
+ seen[e["dir"]] = {"dir": e["dir"], "description": e["description"], "plugin": name or "unknown"}
107
+ order.append(e["dir"])
108
+ return [seen[d] for d in order]
109
+
110
+
111
+ def _allowed_block(text):
112
+ """Return (list_of_entries, start_idx, end_idx) for the fenced
113
+ allowed-top-level block. end_idx is the closing-fence line index."""
114
+ lines = text.split("\n")
115
+ start = None
116
+ for i, ln in enumerate(lines):
117
+ if ln.strip() == "```allowed-top-level":
118
+ start = i
119
+ break
120
+ if start is None:
121
+ return None, None, None
122
+ for j in range(start + 1, len(lines)):
123
+ if lines[j].strip() == "```":
124
+ return [l for l in lines[start + 1:j] if l.strip()], start, j
125
+ return None, None, None
126
+
127
+
128
+ def _strip_region(text):
129
+ if MARK_START not in text:
130
+ return text
131
+ pre = text.split(MARK_START, 1)[0]
132
+ post = ""
133
+ if MARK_END in text:
134
+ post = text.split(MARK_END, 1)[1]
135
+ return (pre.rstrip("\n") + "\n" + post.lstrip("\n")).rstrip("\n") + "\n"
136
+
137
+
138
+ def merge(project_dir, account_dir):
139
+ brand, _ = _read_brand(project_dir)
140
+ owned = resolve(project_dir)
141
+ schema_path = os.path.join(account_dir, "SCHEMA.md")
142
+ if not os.path.isfile(schema_path):
143
+ print(f"brand={brand} added=none")
144
+ return
145
+ with open(schema_path) as f:
146
+ text = f.read()
147
+ entries, start, end = _allowed_block(text)
148
+ added = []
149
+ if entries is not None:
150
+ present = set(entries)
151
+ lines = text.split("\n")
152
+ insert = []
153
+ for e in owned:
154
+ if e["dir"] not in present:
155
+ insert.append(e["dir"])
156
+ added.append(e["dir"])
157
+ if insert:
158
+ lines[end:end] = insert
159
+ text = "\n".join(lines)
160
+ # Regenerate the descriptive region wholesale (idempotent).
161
+ text = _strip_region(text)
162
+ if owned:
163
+ region = [MARK_START, "## Plugin-owned top-level workspaces", "",
164
+ "A plugin that owns a top-level workspace declares it. The internal",
165
+ "structure is owned by that plugin's tools and recreated on the next",
166
+ "write. Never reorganise it.", ""]
167
+ for e in owned:
168
+ desc = e["description"] or f"Owned by the {e['plugin']} plugin."
169
+ region.append(f"- `{e['dir']}/` — {desc}")
170
+ region.append(MARK_END)
171
+ text = text.rstrip("\n") + "\n\n" + "\n".join(region) + "\n"
172
+ with open(schema_path, "w") as f:
173
+ f.write(text)
174
+ print(f"brand={brand} added={','.join(added) if added else 'none'}")
175
+
176
+
177
+ def reconcile(project_dir, account_dir):
178
+ brand, _ = _read_brand(project_dir)
179
+ owned = resolve(project_dir)
180
+ schema_path = os.path.join(account_dir, "SCHEMA.md")
181
+ present = set()
182
+ if os.path.isfile(schema_path):
183
+ with open(schema_path) as f:
184
+ entries, _s, _e = _allowed_block(f.read())
185
+ present = set(entries or [])
186
+ for e in owned:
187
+ is_present = "true" if e["dir"] in present else "false"
188
+ print(f"brand={brand} plugin={e['plugin']} ownedDir={e['dir']} present={is_present}")
189
+
190
+
191
+ def main():
192
+ if len(sys.argv) < 2:
193
+ sys.exit("usage: account-schema-owned-dirs.py {resolve|merge|reconcile} [account_dir]")
194
+ cmd = sys.argv[1]
195
+ pd = _project_dir()
196
+ if cmd == "resolve":
197
+ print(json.dumps(resolve(pd)))
198
+ elif cmd == "merge":
199
+ merge(pd, sys.argv[2])
200
+ elif cmd == "reconcile":
201
+ reconcile(pd, sys.argv[2])
202
+ else:
203
+ sys.exit(f"unknown command: {cmd}")
204
+
205
+
206
+ if __name__ == "__main__":
207
+ main()
@@ -0,0 +1,33 @@
1
+ # account-schema-owned-dirs.sh — merge plugin-declared owned top-level dirs into
2
+ # an account's SCHEMA.md, and reconcile every account at install time.
3
+ #
4
+ # Requires PROJECT_DIR exported by the caller. Sourced by provision-account-dir.sh
5
+ # and setup-account.sh.
6
+
7
+ _ACCT_SCHEMA_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ _ACCT_SCHEMA_PY="$_ACCT_SCHEMA_LIB_DIR/account-schema-owned-dirs.py"
9
+
10
+ # merge_owned_dirs_into_schema <account_dir>
11
+ # Unions the brand's declared owned dirs into <account_dir>/SCHEMA.md and logs
12
+ # the seam. Idempotent.
13
+ merge_owned_dirs_into_schema() {
14
+ local _acct="$1"
15
+ local _out
16
+ _out="$(PROJECT_DIR="$PROJECT_DIR" python3 "$_ACCT_SCHEMA_PY" merge "$_acct" 2>/dev/null)"
17
+ echo " [acct-schema] op=seed-owned-dirs ${_out}"
18
+ }
19
+
20
+ # reconcile_all_accounts_owned_dirs <accounts_dir>
21
+ # Standing check + backfill: for every account dir with an account.json, log the
22
+ # pre-merge present state of each declared owned dir, then merge (backfill).
23
+ reconcile_all_accounts_owned_dirs() {
24
+ local _accts="$1"
25
+ [ -d "$_accts" ] || return 0
26
+ local _acct
27
+ for _acct in "$_accts"/*/; do
28
+ [ -f "$_acct/account.json" ] || continue
29
+ PROJECT_DIR="$PROJECT_DIR" python3 "$_ACCT_SCHEMA_PY" reconcile "$_acct" 2>/dev/null \
30
+ | while IFS= read -r _line; do echo " [acct-schema] op=reconcile ${_line}"; done
31
+ merge_owned_dirs_into_schema "$_acct"
32
+ done
33
+ }
@@ -15,6 +15,9 @@
15
15
  # SCRIPT_DIR — the platform/scripts dir (for lib bootstraps)
16
16
  # USERS_FILE — $HOME/<brand.configDir>/users.json (owner stamp; optional)
17
17
 
18
+ # Owned-dir schema merge (plugin-declared top-level account dirs).
19
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/account-schema-owned-dirs.sh"
20
+
18
21
  provision_account_dir() {
19
22
  local ACCOUNT_DIR="$1"
20
23
  local ACCOUNT_ID="$2"
@@ -38,6 +41,10 @@ provision_account_dir() {
38
41
  fi
39
42
  echo " [acct-schema] seeded dirs=$_seeded"
40
43
 
44
+ # Merge brand-shipped plugin-declared owned top-level dirs into the freshly
45
+ # seeded SCHEMA.md so the guard admits them (e.g. quoting/ on SiteDesk).
46
+ merge_owned_dirs_into_schema "$ACCOUNT_DIR"
47
+
41
48
  # Claude Code discovers project-level .claude/ relative to the nearest .git
42
49
  # root. Without a .git in the account dir it traverses up to ~/maxy/.git and
43
50
  # misses accountDir/.claude/. Initialise a repo so the account dir IS the root.
@@ -53,3 +53,13 @@ USERS_FILE="$USERS_FILE" resolve_and_sweep_account_dir
53
53
  # designation (migrating a legacy unlabelled account in the same step).
54
54
  export TEMPLATES_DIR PROJECT_DIR SCRIPT_DIR USERS_FILE
55
55
  provision_account_dir "$ACCOUNT_DIR" "$ACCOUNT_ID" "house"
56
+
57
+ # ------------------------------------------------------------------
58
+ # 3. Reconcile plugin-declared owned top-level dirs across every account
59
+ # ------------------------------------------------------------------
60
+ # Standing check + durable backfill: every account under data/accounts/ gets the
61
+ # brand's plugin-declared owned dirs unioned into its SCHEMA.md. Runs on every
62
+ # install so a re-provision cannot drop a previously-added owned dir.
63
+ # reconcile_all_accounts_owned_dirs is available because provision-account-dir.sh
64
+ # (sourced above) sources the owned-dir lib.
65
+ reconcile_all_accounts_owned_dirs "$ACCOUNTS_DIR"
@@ -1 +1 @@
1
- {"version":3,"file":"canonical-tool-names.generated.d.ts","sourceRoot":"","sources":["../src/canonical-tool-names.generated.ts"],"names":[],"mappings":"AAQA,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,EAAE,SAAS,MAAM,EAqB7C,CAAA;AAED,gEAAgE;AAChE,eAAO,MAAM,yBAAyB,EAAE,SAAS,MAAM,EA4QtD,CAAA"}
1
+ {"version":3,"file":"canonical-tool-names.generated.d.ts","sourceRoot":"","sources":["../src/canonical-tool-names.generated.ts"],"names":[],"mappings":"AAQA,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,EAAE,SAAS,MAAM,EAqB7C,CAAA;AAED,gEAAgE;AAChE,eAAO,MAAM,yBAAyB,EAAE,SAAS,MAAM,EA6QtD,CAAA"}
@@ -232,6 +232,7 @@ export const CANONICAL_MAXY_TOOL_NAMES = [
232
232
  "mcp__plugin_outlook_outlook__outlook-mail-search",
233
233
  "mcp__plugin_outlook_outlook__outlook-mail-send",
234
234
  "mcp__plugin_outlook_outlook__outlook-mailbox-info",
235
+ "mcp__plugin_outlook_outlook__outlook-mailbox-list",
235
236
  "mcp__plugin_quickbooks_quickbooks__quickbooks-authorize-url",
236
237
  "mcp__plugin_quickbooks_quickbooks__quickbooks-bill-create",
237
238
  "mcp__plugin_quickbooks_quickbooks__quickbooks-connections",