@mulmoclaude/collection-plugin 0.5.1 → 0.5.3
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/dist/core/schema.d.ts +24 -2
- package/dist/core/schema.d.ts.map +1 -1
- package/dist/index.cjs +2 -1
- package/dist/index.js +2 -2
- package/dist/{schema-LrmrgW12.cjs → schema-5P5ZJPrY.cjs} +11 -1
- package/dist/schema-5P5ZJPrY.cjs.map +1 -0
- package/dist/{schema-DyfSfVzh.js → schema-BcnitlL8.js} +6 -2
- package/dist/schema-BcnitlL8.js.map +1 -0
- package/dist/server/discovery.d.ts +43 -3
- package/dist/server/discovery.d.ts.map +1 -1
- package/dist/server/paths.d.ts +6 -0
- package/dist/server/paths.d.ts.map +1 -1
- package/dist/server/spawn.d.ts +8 -1
- package/dist/server/spawn.d.ts.map +1 -1
- package/dist/server.cjs +153 -39
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +151 -40
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/dist/schema-DyfSfVzh.js.map +0 -1
- package/dist/schema-LrmrgW12.cjs.map +0 -1
package/dist/server.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_chunk = require("./chunk-CKQMccvm.cjs");
|
|
3
|
-
const require_schema = require("./schema-
|
|
3
|
+
const require_schema = require("./schema-5P5ZJPrY.cjs");
|
|
4
4
|
const require_deriveAll = require("./deriveAll-BJ0Lvm1Q.cjs");
|
|
5
5
|
let node_path = require("node:path");
|
|
6
6
|
node_path = require_chunk.__toESM(node_path, 1);
|
|
@@ -72,6 +72,20 @@ function safeSlugName(slug) {
|
|
|
72
72
|
if (basename !== slug) return null;
|
|
73
73
|
return basename;
|
|
74
74
|
}
|
|
75
|
+
var SAFE_RECORD_ID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
|
|
76
|
+
/** Sanitise a user-supplied record id into a safe filename stem. Like
|
|
77
|
+
* `safeSlugName` but tolerates interior dots (so natural keys work),
|
|
78
|
+
* while still rejecting any `..` substring, path separators, and
|
|
79
|
+
* leading/trailing dots. The `path.basename` round-trip is the same
|
|
80
|
+
* `js/path-injection` sanitiser CodeQL recognises on `safeSlugName`. */
|
|
81
|
+
function safeRecordId(recordId) {
|
|
82
|
+
if (typeof recordId !== "string") return null;
|
|
83
|
+
if (!SAFE_RECORD_ID_PATTERN.test(recordId)) return null;
|
|
84
|
+
if (recordId.includes("..")) return null;
|
|
85
|
+
const basename = node_path.default.basename(recordId);
|
|
86
|
+
if (basename !== recordId) return null;
|
|
87
|
+
return basename;
|
|
88
|
+
}
|
|
75
89
|
/** Realpath the closest existing ancestor of `absPath` and return it.
|
|
76
90
|
* Returns null if no ancestor exists or if the realpath call fails
|
|
77
91
|
* for a non-ENOENT reason (permissions, etc.). Used by
|
|
@@ -305,7 +319,7 @@ async function listItems(dataDir, opts = {}) {
|
|
|
305
319
|
* when the record file itself is a symlink (file-disclosure
|
|
306
320
|
* defense — see `isRegularFile`). */
|
|
307
321
|
async function readItem(dataDir, itemId, opts = {}) {
|
|
308
|
-
const safeId =
|
|
322
|
+
const safeId = safeRecordId(itemId);
|
|
309
323
|
if (safeId === null) return null;
|
|
310
324
|
if (!isContainedInRoot(dataDir, opts.workspaceRoot ?? getWorkspaceRoot())) return null;
|
|
311
325
|
const filePath = itemFilePath(dataDir, safeId);
|
|
@@ -335,7 +349,7 @@ async function readItem(dataDir, itemId, opts = {}) {
|
|
|
335
349
|
* Update path (`refuseOverwrite: false`) uses `writeFileAtomic` so
|
|
336
350
|
* PUT remains crash-atomic. No race there — the URL pins the id. */
|
|
337
351
|
async function writeItem(dataDir, itemId, item, opts = {}) {
|
|
338
|
-
const safeId =
|
|
352
|
+
const safeId = safeRecordId(itemId);
|
|
339
353
|
if (safeId === null) return {
|
|
340
354
|
kind: "invalid-id",
|
|
341
355
|
itemId
|
|
@@ -394,7 +408,7 @@ async function writeItem(dataDir, itemId, item, opts = {}) {
|
|
|
394
408
|
};
|
|
395
409
|
}
|
|
396
410
|
async function deleteItem(dataDir, itemId, opts = {}) {
|
|
397
|
-
const safeId =
|
|
411
|
+
const safeId = safeRecordId(itemId);
|
|
398
412
|
if (safeId === null) return {
|
|
399
413
|
kind: "invalid-id",
|
|
400
414
|
itemId
|
|
@@ -766,7 +780,7 @@ var CustomViewSchema = zod.z.object({
|
|
|
766
780
|
file: zod.z.string().trim().min(1).refine(isSafeCustomViewPath, "must be a safe path under `views/` ending in `.html` (e.g. `views/year.html`; no `..`, no leading `/`, no backslash)"),
|
|
767
781
|
capabilities: zod.z.array(zod.z.enum(["read", "write"])).optional()
|
|
768
782
|
});
|
|
769
|
-
var
|
|
783
|
+
var EveryLiteralSchema = zod.z.object({
|
|
770
784
|
unit: zod.z.enum([
|
|
771
785
|
"day",
|
|
772
786
|
"week",
|
|
@@ -775,7 +789,12 @@ var EverySchema = zod.z.object({
|
|
|
775
789
|
]),
|
|
776
790
|
interval: zod.z.number().int().min(1),
|
|
777
791
|
dayOfMonth: zod.z.union([zod.z.number().int().min(1).max(31), zod.z.literal("last")]).optional()
|
|
778
|
-
});
|
|
792
|
+
}).strict();
|
|
793
|
+
var EveryFieldDrivenSchema = zod.z.object({
|
|
794
|
+
fromField: zod.z.string().trim().min(1),
|
|
795
|
+
map: zod.z.record(zod.z.string(), EveryLiteralSchema)
|
|
796
|
+
}).strict();
|
|
797
|
+
var EverySchema = zod.z.union([EveryLiteralSchema, EveryFieldDrivenSchema]);
|
|
779
798
|
var SpawnSchema = zod.z.object({
|
|
780
799
|
when: WhenSchema.optional(),
|
|
781
800
|
every: EverySchema,
|
|
@@ -817,6 +836,36 @@ function spawnSuccessorStartsInert(schema) {
|
|
|
817
836
|
if (spawn.set && Object.prototype.hasOwnProperty.call(spawn.set, field)) return !values.includes(String(spawn.set[field]));
|
|
818
837
|
return !(spawn.carry ?? []).includes(field);
|
|
819
838
|
}
|
|
839
|
+
function fieldDrivenSpawnEvery(schema) {
|
|
840
|
+
const every = schema.spawn?.every;
|
|
841
|
+
if (!every || !require_schema.isFieldDrivenEvery(every)) return null;
|
|
842
|
+
return every;
|
|
843
|
+
}
|
|
844
|
+
function fieldDrivenFromFieldIsEnum(schema) {
|
|
845
|
+
const driven = fieldDrivenSpawnEvery(schema);
|
|
846
|
+
if (!driven) return true;
|
|
847
|
+
return schema.fields[driven.fromField]?.type === "enum";
|
|
848
|
+
}
|
|
849
|
+
function fieldDrivenMapCoversValues(schema) {
|
|
850
|
+
const driven = fieldDrivenSpawnEvery(schema);
|
|
851
|
+
if (!driven) return true;
|
|
852
|
+
const target = schema.fields[driven.fromField];
|
|
853
|
+
if (!target || target.type !== "enum" || target.values === void 0) return true;
|
|
854
|
+
const values = new Set(target.values);
|
|
855
|
+
const keys = Object.keys(driven.map);
|
|
856
|
+
return keys.length === values.size && keys.every((key) => values.has(key));
|
|
857
|
+
}
|
|
858
|
+
function fieldDrivenFromFieldCarried(schema) {
|
|
859
|
+
const driven = fieldDrivenSpawnEvery(schema);
|
|
860
|
+
if (!driven) return true;
|
|
861
|
+
const { carry, set } = schema.spawn ?? {};
|
|
862
|
+
if (set && Object.prototype.hasOwnProperty.call(set, driven.fromField)) {
|
|
863
|
+
const raw = set[driven.fromField];
|
|
864
|
+
if (raw === void 0 || raw === null || raw === "") return false;
|
|
865
|
+
return Object.prototype.hasOwnProperty.call(driven.map, String(raw));
|
|
866
|
+
}
|
|
867
|
+
return (carry ?? []).includes(driven.fromField);
|
|
868
|
+
}
|
|
820
869
|
var IngestSchemaZ = zod.z.object({
|
|
821
870
|
kind: zod.z.enum(require_schema.INGEST_KINDS),
|
|
822
871
|
url: zod.z.string().url(),
|
|
@@ -848,8 +897,8 @@ var CollectionSchemaZ = zod.z.object({
|
|
|
848
897
|
views: zod.z.array(CustomViewSchema).optional(),
|
|
849
898
|
notifyWhen: WhenSchema.optional(),
|
|
850
899
|
ingest: IngestSchemaZ.optional()
|
|
851
|
-
}).refine((schema) => schema.singleton === void 0 ||
|
|
852
|
-
message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore, no path separators)",
|
|
900
|
+
}).refine((schema) => schema.singleton === void 0 || safeRecordId(schema.singleton) !== null, {
|
|
901
|
+
message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore / interior dot, no `..` or path separators)",
|
|
853
902
|
path: ["singleton"]
|
|
854
903
|
}).refine((schema) => schema.actions === void 0 || new Set(schema.actions.map((action) => action.id)).size === schema.actions.length, {
|
|
855
904
|
message: "schema `actions` must have unique `id`s",
|
|
@@ -893,6 +942,15 @@ var CollectionSchemaZ = zod.z.object({
|
|
|
893
942
|
}).refine((schema) => spawnSuccessorStartsInert(schema), {
|
|
894
943
|
message: "`spawn` must leave the successor in a non-matching state (e.g. `set` the status to a pending value); seeding the predicate field to a matching value via `set`/`carry` would respawn forever",
|
|
895
944
|
path: ["spawn"]
|
|
945
|
+
}).refine((schema) => fieldDrivenFromFieldIsEnum(schema), {
|
|
946
|
+
message: "`spawn.every.fromField` must name a top-level `enum` field declared in `fields`",
|
|
947
|
+
path: ["spawn"]
|
|
948
|
+
}).refine((schema) => fieldDrivenMapCoversValues(schema), {
|
|
949
|
+
message: "`spawn.every.map` keys must exactly cover the `values` of the `enum` named by `fromField` (no missing or extra keys)",
|
|
950
|
+
path: ["spawn"]
|
|
951
|
+
}).refine((schema) => fieldDrivenFromFieldCarried(schema), {
|
|
952
|
+
message: "`spawn.every.fromField` must appear in `spawn.carry`, or be written by `spawn.set` to a value present in `spawn.every.map`, so the successor keeps a resolvable recurrence interval",
|
|
953
|
+
path: ["spawn"]
|
|
896
954
|
}).refine((schema) => schema.calendarField === void 0 || isDateLike(schema.fields[schema.calendarField]?.type), {
|
|
897
955
|
message: "schema `calendarField` must name a top-level `date` or `datetime` field declared in `fields`",
|
|
898
956
|
path: ["calendarField"]
|
|
@@ -940,6 +998,45 @@ function applyFeedSchemaDefaults(parsed, slug) {
|
|
|
940
998
|
dataPath: `data/feeds/${slug}`
|
|
941
999
|
};
|
|
942
1000
|
}
|
|
1001
|
+
/** The acceptance gates discovery applies AFTER `CollectionSchemaZ` parses,
|
|
1002
|
+
* before a schema becomes a live collection:
|
|
1003
|
+
*
|
|
1004
|
+
* - the `primaryKey` must be a declared field flagged `primary: true` —
|
|
1005
|
+
* without the flag CollectionView renders the field editable, and a
|
|
1006
|
+
* rename is silently pinned back to the URL itemId on save, so the user's
|
|
1007
|
+
* edit is dropped with no error;
|
|
1008
|
+
* - a `feed` schema must declare an `ingest` block (else it's a dead,
|
|
1009
|
+
* non-refreshable card);
|
|
1010
|
+
* - `dataPath` must resolve INSIDE the workspace.
|
|
1011
|
+
*
|
|
1012
|
+
* Exported so `manageCollection`'s `putSchema` can run the SAME gates before
|
|
1013
|
+
* it reports success — a schema that passes `CollectionSchemaZ` but fails one
|
|
1014
|
+
* of these would otherwise write cleanly yet be skipped on the next discovery,
|
|
1015
|
+
* hiding the collection (the exact failure that tool exists to prevent). */
|
|
1016
|
+
function acceptParsedSchema(schema, opts) {
|
|
1017
|
+
const primaryField = schema.fields[schema.primaryKey];
|
|
1018
|
+
if (!primaryField) return {
|
|
1019
|
+
ok: false,
|
|
1020
|
+
reason: `primaryKey '${schema.primaryKey}' is not one of the declared fields`
|
|
1021
|
+
};
|
|
1022
|
+
if (primaryField.primary !== true) return {
|
|
1023
|
+
ok: false,
|
|
1024
|
+
reason: `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``
|
|
1025
|
+
};
|
|
1026
|
+
if (opts.source === "feed" && !schema.ingest) return {
|
|
1027
|
+
ok: false,
|
|
1028
|
+
reason: "a feed schema must declare an `ingest` block"
|
|
1029
|
+
};
|
|
1030
|
+
const dataDir = resolveDataDir(schema.dataPath, opts.workspaceRoot);
|
|
1031
|
+
if (dataDir === null) return {
|
|
1032
|
+
ok: false,
|
|
1033
|
+
reason: `dataPath '${schema.dataPath}' escapes the workspace`
|
|
1034
|
+
};
|
|
1035
|
+
return {
|
|
1036
|
+
ok: true,
|
|
1037
|
+
dataDir
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
943
1040
|
async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
|
|
944
1041
|
const safeName = safeSlugName(slug);
|
|
945
1042
|
if (safeName === null) return null;
|
|
@@ -976,31 +1073,14 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
|
|
|
976
1073
|
return null;
|
|
977
1074
|
}
|
|
978
1075
|
const schema = parsed.data;
|
|
979
|
-
const
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
return null;
|
|
986
|
-
}
|
|
987
|
-
if (primaryField.primary !== true) {
|
|
988
|
-
log.warn("collections", "schema.json primaryKey field is not flagged primary: true, skipping", {
|
|
989
|
-
slug: safeName,
|
|
990
|
-
primaryKey: schema.primaryKey
|
|
991
|
-
});
|
|
992
|
-
return null;
|
|
993
|
-
}
|
|
994
|
-
if (source === "feed" && !schema.ingest) {
|
|
995
|
-
log.warn("collections", "feed schema.json has no `ingest` block, skipping", { slug: safeName });
|
|
996
|
-
return null;
|
|
997
|
-
}
|
|
998
|
-
const dataDir = resolveDataDir(schema.dataPath, workspaceRoot);
|
|
999
|
-
if (dataDir === null) {
|
|
1000
|
-
log.warn("collections", "schema.json dataPath escapes workspace, skipping", {
|
|
1076
|
+
const acceptance = acceptParsedSchema(schema, {
|
|
1077
|
+
source,
|
|
1078
|
+
workspaceRoot
|
|
1079
|
+
});
|
|
1080
|
+
if (!acceptance.ok) {
|
|
1081
|
+
log.warn("collections", "schema.json rejected after validation, skipping", {
|
|
1001
1082
|
slug: safeName,
|
|
1002
|
-
|
|
1003
|
-
workspaceRoot
|
|
1083
|
+
reason: acceptance.reason
|
|
1004
1084
|
});
|
|
1005
1085
|
return null;
|
|
1006
1086
|
}
|
|
@@ -1008,7 +1088,7 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
|
|
|
1008
1088
|
slug: safeName,
|
|
1009
1089
|
source,
|
|
1010
1090
|
schema,
|
|
1011
|
-
dataDir,
|
|
1091
|
+
dataDir: acceptance.dataDir,
|
|
1012
1092
|
skillDir: node_path.default.join(skillsRoot, safeName)
|
|
1013
1093
|
};
|
|
1014
1094
|
}
|
|
@@ -1277,6 +1357,18 @@ function matchesWhen(when, schema, item) {
|
|
|
1277
1357
|
const raw = item[completionField];
|
|
1278
1358
|
return raw !== void 0 && raw !== null && completionDoneValues.includes(String(raw));
|
|
1279
1359
|
}
|
|
1360
|
+
/** Resolve the literal `every` that applies to `sourceItem`. Literal-arm
|
|
1361
|
+
* `spawn.every` passes through unchanged. Field-driven `spawn.every` reads
|
|
1362
|
+
* `sourceItem[fromField]` and looks it up in `map`; an empty field or a
|
|
1363
|
+
* value with no map entry yields null (caller skips + logs — see plan §5).
|
|
1364
|
+
* Discovery rejects a map that doesn't cover the enum's values, so null
|
|
1365
|
+
* here means a record that predates a map/enum edit. */
|
|
1366
|
+
function resolveEvery(every, sourceItem) {
|
|
1367
|
+
if (!require_schema.isFieldDrivenEvery(every)) return every;
|
|
1368
|
+
const raw = sourceItem[every.fromField];
|
|
1369
|
+
if (raw === void 0 || raw === null || raw === "") return null;
|
|
1370
|
+
return every.map[String(raw)] ?? null;
|
|
1371
|
+
}
|
|
1280
1372
|
/** Build the successor record purely from (schema, source record, source
|
|
1281
1373
|
* id). Returns null when the schema has no spawn/triggerField or the
|
|
1282
1374
|
* source's trigger date is unparseable. */
|
|
@@ -1285,7 +1377,9 @@ function computeSuccessor(schema, sourceItem, sourceId) {
|
|
|
1285
1377
|
if (!spawn || !triggerField) return null;
|
|
1286
1378
|
const srcDate = parseCivil(sourceItem[triggerField]);
|
|
1287
1379
|
if (srcDate === null) return null;
|
|
1288
|
-
const
|
|
1380
|
+
const every = resolveEvery(spawn.every, sourceItem);
|
|
1381
|
+
if (every === null) return null;
|
|
1382
|
+
const next = advanceTriggerDate(srcDate, every);
|
|
1289
1383
|
const nextId = successorId(sourceId, next);
|
|
1290
1384
|
const record = {};
|
|
1291
1385
|
for (const field of spawn.carry ?? []) if (Object.prototype.hasOwnProperty.call(sourceItem, field)) record[field] = sourceItem[field];
|
|
@@ -1297,6 +1391,27 @@ function computeSuccessor(schema, sourceItem, sourceId) {
|
|
|
1297
1391
|
record
|
|
1298
1392
|
};
|
|
1299
1393
|
}
|
|
1394
|
+
/** Warn precisely about which of `computeSuccessor`'s two null causes fired
|
|
1395
|
+
* (plan §5): an unparseable source trigger date (the original cause), or a
|
|
1396
|
+
* field-driven `every` whose record value has no `map` entry (a record that
|
|
1397
|
+
* predates a map/enum edit — discovery rejects this statically otherwise). */
|
|
1398
|
+
function logSpawnSkip(slug, triggerField, every, sourceItem, sourceId) {
|
|
1399
|
+
if (parseCivil(sourceItem[triggerField]) === null) {
|
|
1400
|
+
log.warn("collections", "spawn skipped: source trigger date unparseable", {
|
|
1401
|
+
slug,
|
|
1402
|
+
sourceId,
|
|
1403
|
+
triggerField
|
|
1404
|
+
});
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
const fromField = require_schema.isFieldDrivenEvery(every) ? every.fromField : void 0;
|
|
1408
|
+
log.warn("collections", "spawn skipped: no `every` mapping for frequency value", {
|
|
1409
|
+
slug,
|
|
1410
|
+
sourceId,
|
|
1411
|
+
fromField,
|
|
1412
|
+
value: fromField === void 0 ? void 0 : sourceItem[fromField]
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1300
1415
|
/** Idempotently create the successor for `sourceItem` when it matches the
|
|
1301
1416
|
* spawn predicate. No-op when the schema declares no spawn, the
|
|
1302
1417
|
* predicate doesn't match, the trigger date is unparseable, or the
|
|
@@ -1308,11 +1423,7 @@ async function maybeSpawnSuccessor(slug, schema, dataDir, sourceItem, sourceId,
|
|
|
1308
1423
|
if (!matchesWhen(spawn.when, schema, sourceItem)) return;
|
|
1309
1424
|
const computed = computeSuccessor(schema, sourceItem, sourceId);
|
|
1310
1425
|
if (computed === null) {
|
|
1311
|
-
|
|
1312
|
-
slug,
|
|
1313
|
-
sourceId,
|
|
1314
|
-
triggerField: schema.triggerField
|
|
1315
|
-
});
|
|
1426
|
+
logSpawnSkip(slug, schema.triggerField, spawn.every, sourceItem, sourceId);
|
|
1316
1427
|
return;
|
|
1317
1428
|
}
|
|
1318
1429
|
if (matchesWhen(spawn.when, schema, computed.record)) {
|
|
@@ -1577,6 +1688,7 @@ async function deleteCustomView(collection, viewId, opts = {}) {
|
|
|
1577
1688
|
exports.COMPUTED_TYPES = COMPUTED_TYPES;
|
|
1578
1689
|
exports.CollectionSchemaZ = CollectionSchemaZ;
|
|
1579
1690
|
exports.SCHEMA_FILE = SCHEMA_FILE;
|
|
1691
|
+
exports.acceptParsedSchema = acceptParsedSchema;
|
|
1580
1692
|
exports.advanceTriggerDate = advanceTriggerDate;
|
|
1581
1693
|
exports.buildActionSeedPrompt = buildActionSeedPrompt;
|
|
1582
1694
|
exports.buildCollectionActionSeedPrompt = buildCollectionActionSeedPrompt;
|
|
@@ -1609,7 +1721,9 @@ exports.readItem = readItem;
|
|
|
1609
1721
|
exports.readSkillTemplate = readSkillTemplate;
|
|
1610
1722
|
exports.resolveCreateItemId = resolveCreateItemId;
|
|
1611
1723
|
exports.resolveDataDir = resolveDataDir;
|
|
1724
|
+
exports.resolveEvery = resolveEvery;
|
|
1612
1725
|
exports.resolveTemplatePath = resolveTemplatePath;
|
|
1726
|
+
exports.safeRecordId = safeRecordId;
|
|
1613
1727
|
exports.safeSlugName = safeSlugName;
|
|
1614
1728
|
exports.successorId = successorId;
|
|
1615
1729
|
exports.toDetail = toDetail;
|