@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.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as INGEST_KINDS, t as FEED_SCHEDULES } from "./schema-
|
|
1
|
+
import { n as INGEST_KINDS, r as isFieldDrivenEvery, t as FEED_SCHEDULES } from "./schema-BcnitlL8.js";
|
|
2
2
|
import { t as deriveAll } from "./deriveAll-BHcs1erT.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { promises, realpathSync } from "node:fs";
|
|
@@ -69,6 +69,20 @@ function safeSlugName(slug) {
|
|
|
69
69
|
if (basename !== slug) return null;
|
|
70
70
|
return basename;
|
|
71
71
|
}
|
|
72
|
+
var SAFE_RECORD_ID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
|
|
73
|
+
/** Sanitise a user-supplied record id into a safe filename stem. Like
|
|
74
|
+
* `safeSlugName` but tolerates interior dots (so natural keys work),
|
|
75
|
+
* while still rejecting any `..` substring, path separators, and
|
|
76
|
+
* leading/trailing dots. The `path.basename` round-trip is the same
|
|
77
|
+
* `js/path-injection` sanitiser CodeQL recognises on `safeSlugName`. */
|
|
78
|
+
function safeRecordId(recordId) {
|
|
79
|
+
if (typeof recordId !== "string") return null;
|
|
80
|
+
if (!SAFE_RECORD_ID_PATTERN.test(recordId)) return null;
|
|
81
|
+
if (recordId.includes("..")) return null;
|
|
82
|
+
const basename = path.basename(recordId);
|
|
83
|
+
if (basename !== recordId) return null;
|
|
84
|
+
return basename;
|
|
85
|
+
}
|
|
72
86
|
/** Realpath the closest existing ancestor of `absPath` and return it.
|
|
73
87
|
* Returns null if no ancestor exists or if the realpath call fails
|
|
74
88
|
* for a non-ENOENT reason (permissions, etc.). Used by
|
|
@@ -302,7 +316,7 @@ async function listItems(dataDir, opts = {}) {
|
|
|
302
316
|
* when the record file itself is a symlink (file-disclosure
|
|
303
317
|
* defense — see `isRegularFile`). */
|
|
304
318
|
async function readItem(dataDir, itemId, opts = {}) {
|
|
305
|
-
const safeId =
|
|
319
|
+
const safeId = safeRecordId(itemId);
|
|
306
320
|
if (safeId === null) return null;
|
|
307
321
|
if (!isContainedInRoot(dataDir, opts.workspaceRoot ?? getWorkspaceRoot())) return null;
|
|
308
322
|
const filePath = itemFilePath(dataDir, safeId);
|
|
@@ -332,7 +346,7 @@ async function readItem(dataDir, itemId, opts = {}) {
|
|
|
332
346
|
* Update path (`refuseOverwrite: false`) uses `writeFileAtomic` so
|
|
333
347
|
* PUT remains crash-atomic. No race there — the URL pins the id. */
|
|
334
348
|
async function writeItem(dataDir, itemId, item, opts = {}) {
|
|
335
|
-
const safeId =
|
|
349
|
+
const safeId = safeRecordId(itemId);
|
|
336
350
|
if (safeId === null) return {
|
|
337
351
|
kind: "invalid-id",
|
|
338
352
|
itemId
|
|
@@ -391,7 +405,7 @@ async function writeItem(dataDir, itemId, item, opts = {}) {
|
|
|
391
405
|
};
|
|
392
406
|
}
|
|
393
407
|
async function deleteItem(dataDir, itemId, opts = {}) {
|
|
394
|
-
const safeId =
|
|
408
|
+
const safeId = safeRecordId(itemId);
|
|
395
409
|
if (safeId === null) return {
|
|
396
410
|
kind: "invalid-id",
|
|
397
411
|
itemId
|
|
@@ -763,7 +777,7 @@ var CustomViewSchema = z.object({
|
|
|
763
777
|
file: 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)"),
|
|
764
778
|
capabilities: z.array(z.enum(["read", "write"])).optional()
|
|
765
779
|
});
|
|
766
|
-
var
|
|
780
|
+
var EveryLiteralSchema = z.object({
|
|
767
781
|
unit: z.enum([
|
|
768
782
|
"day",
|
|
769
783
|
"week",
|
|
@@ -772,7 +786,12 @@ var EverySchema = z.object({
|
|
|
772
786
|
]),
|
|
773
787
|
interval: z.number().int().min(1),
|
|
774
788
|
dayOfMonth: z.union([z.number().int().min(1).max(31), z.literal("last")]).optional()
|
|
775
|
-
});
|
|
789
|
+
}).strict();
|
|
790
|
+
var EveryFieldDrivenSchema = z.object({
|
|
791
|
+
fromField: z.string().trim().min(1),
|
|
792
|
+
map: z.record(z.string(), EveryLiteralSchema)
|
|
793
|
+
}).strict();
|
|
794
|
+
var EverySchema = z.union([EveryLiteralSchema, EveryFieldDrivenSchema]);
|
|
776
795
|
var SpawnSchema = z.object({
|
|
777
796
|
when: WhenSchema.optional(),
|
|
778
797
|
every: EverySchema,
|
|
@@ -814,6 +833,36 @@ function spawnSuccessorStartsInert(schema) {
|
|
|
814
833
|
if (spawn.set && Object.prototype.hasOwnProperty.call(spawn.set, field)) return !values.includes(String(spawn.set[field]));
|
|
815
834
|
return !(spawn.carry ?? []).includes(field);
|
|
816
835
|
}
|
|
836
|
+
function fieldDrivenSpawnEvery(schema) {
|
|
837
|
+
const every = schema.spawn?.every;
|
|
838
|
+
if (!every || !isFieldDrivenEvery(every)) return null;
|
|
839
|
+
return every;
|
|
840
|
+
}
|
|
841
|
+
function fieldDrivenFromFieldIsEnum(schema) {
|
|
842
|
+
const driven = fieldDrivenSpawnEvery(schema);
|
|
843
|
+
if (!driven) return true;
|
|
844
|
+
return schema.fields[driven.fromField]?.type === "enum";
|
|
845
|
+
}
|
|
846
|
+
function fieldDrivenMapCoversValues(schema) {
|
|
847
|
+
const driven = fieldDrivenSpawnEvery(schema);
|
|
848
|
+
if (!driven) return true;
|
|
849
|
+
const target = schema.fields[driven.fromField];
|
|
850
|
+
if (!target || target.type !== "enum" || target.values === void 0) return true;
|
|
851
|
+
const values = new Set(target.values);
|
|
852
|
+
const keys = Object.keys(driven.map);
|
|
853
|
+
return keys.length === values.size && keys.every((key) => values.has(key));
|
|
854
|
+
}
|
|
855
|
+
function fieldDrivenFromFieldCarried(schema) {
|
|
856
|
+
const driven = fieldDrivenSpawnEvery(schema);
|
|
857
|
+
if (!driven) return true;
|
|
858
|
+
const { carry, set } = schema.spawn ?? {};
|
|
859
|
+
if (set && Object.prototype.hasOwnProperty.call(set, driven.fromField)) {
|
|
860
|
+
const raw = set[driven.fromField];
|
|
861
|
+
if (raw === void 0 || raw === null || raw === "") return false;
|
|
862
|
+
return Object.prototype.hasOwnProperty.call(driven.map, String(raw));
|
|
863
|
+
}
|
|
864
|
+
return (carry ?? []).includes(driven.fromField);
|
|
865
|
+
}
|
|
817
866
|
var IngestSchemaZ = z.object({
|
|
818
867
|
kind: z.enum(INGEST_KINDS),
|
|
819
868
|
url: z.string().url(),
|
|
@@ -845,8 +894,8 @@ var CollectionSchemaZ = z.object({
|
|
|
845
894
|
views: z.array(CustomViewSchema).optional(),
|
|
846
895
|
notifyWhen: WhenSchema.optional(),
|
|
847
896
|
ingest: IngestSchemaZ.optional()
|
|
848
|
-
}).refine((schema) => schema.singleton === void 0 ||
|
|
849
|
-
message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore, no path separators)",
|
|
897
|
+
}).refine((schema) => schema.singleton === void 0 || safeRecordId(schema.singleton) !== null, {
|
|
898
|
+
message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore / interior dot, no `..` or path separators)",
|
|
850
899
|
path: ["singleton"]
|
|
851
900
|
}).refine((schema) => schema.actions === void 0 || new Set(schema.actions.map((action) => action.id)).size === schema.actions.length, {
|
|
852
901
|
message: "schema `actions` must have unique `id`s",
|
|
@@ -890,6 +939,15 @@ var CollectionSchemaZ = z.object({
|
|
|
890
939
|
}).refine((schema) => spawnSuccessorStartsInert(schema), {
|
|
891
940
|
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",
|
|
892
941
|
path: ["spawn"]
|
|
942
|
+
}).refine((schema) => fieldDrivenFromFieldIsEnum(schema), {
|
|
943
|
+
message: "`spawn.every.fromField` must name a top-level `enum` field declared in `fields`",
|
|
944
|
+
path: ["spawn"]
|
|
945
|
+
}).refine((schema) => fieldDrivenMapCoversValues(schema), {
|
|
946
|
+
message: "`spawn.every.map` keys must exactly cover the `values` of the `enum` named by `fromField` (no missing or extra keys)",
|
|
947
|
+
path: ["spawn"]
|
|
948
|
+
}).refine((schema) => fieldDrivenFromFieldCarried(schema), {
|
|
949
|
+
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",
|
|
950
|
+
path: ["spawn"]
|
|
893
951
|
}).refine((schema) => schema.calendarField === void 0 || isDateLike(schema.fields[schema.calendarField]?.type), {
|
|
894
952
|
message: "schema `calendarField` must name a top-level `date` or `datetime` field declared in `fields`",
|
|
895
953
|
path: ["calendarField"]
|
|
@@ -937,6 +995,45 @@ function applyFeedSchemaDefaults(parsed, slug) {
|
|
|
937
995
|
dataPath: `data/feeds/${slug}`
|
|
938
996
|
};
|
|
939
997
|
}
|
|
998
|
+
/** The acceptance gates discovery applies AFTER `CollectionSchemaZ` parses,
|
|
999
|
+
* before a schema becomes a live collection:
|
|
1000
|
+
*
|
|
1001
|
+
* - the `primaryKey` must be a declared field flagged `primary: true` —
|
|
1002
|
+
* without the flag CollectionView renders the field editable, and a
|
|
1003
|
+
* rename is silently pinned back to the URL itemId on save, so the user's
|
|
1004
|
+
* edit is dropped with no error;
|
|
1005
|
+
* - a `feed` schema must declare an `ingest` block (else it's a dead,
|
|
1006
|
+
* non-refreshable card);
|
|
1007
|
+
* - `dataPath` must resolve INSIDE the workspace.
|
|
1008
|
+
*
|
|
1009
|
+
* Exported so `manageCollection`'s `putSchema` can run the SAME gates before
|
|
1010
|
+
* it reports success — a schema that passes `CollectionSchemaZ` but fails one
|
|
1011
|
+
* of these would otherwise write cleanly yet be skipped on the next discovery,
|
|
1012
|
+
* hiding the collection (the exact failure that tool exists to prevent). */
|
|
1013
|
+
function acceptParsedSchema(schema, opts) {
|
|
1014
|
+
const primaryField = schema.fields[schema.primaryKey];
|
|
1015
|
+
if (!primaryField) return {
|
|
1016
|
+
ok: false,
|
|
1017
|
+
reason: `primaryKey '${schema.primaryKey}' is not one of the declared fields`
|
|
1018
|
+
};
|
|
1019
|
+
if (primaryField.primary !== true) return {
|
|
1020
|
+
ok: false,
|
|
1021
|
+
reason: `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``
|
|
1022
|
+
};
|
|
1023
|
+
if (opts.source === "feed" && !schema.ingest) return {
|
|
1024
|
+
ok: false,
|
|
1025
|
+
reason: "a feed schema must declare an `ingest` block"
|
|
1026
|
+
};
|
|
1027
|
+
const dataDir = resolveDataDir(schema.dataPath, opts.workspaceRoot);
|
|
1028
|
+
if (dataDir === null) return {
|
|
1029
|
+
ok: false,
|
|
1030
|
+
reason: `dataPath '${schema.dataPath}' escapes the workspace`
|
|
1031
|
+
};
|
|
1032
|
+
return {
|
|
1033
|
+
ok: true,
|
|
1034
|
+
dataDir
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
940
1037
|
async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
|
|
941
1038
|
const safeName = safeSlugName(slug);
|
|
942
1039
|
if (safeName === null) return null;
|
|
@@ -973,31 +1070,14 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
|
|
|
973
1070
|
return null;
|
|
974
1071
|
}
|
|
975
1072
|
const schema = parsed.data;
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
return null;
|
|
983
|
-
}
|
|
984
|
-
if (primaryField.primary !== true) {
|
|
985
|
-
log.warn("collections", "schema.json primaryKey field is not flagged primary: true, skipping", {
|
|
986
|
-
slug: safeName,
|
|
987
|
-
primaryKey: schema.primaryKey
|
|
988
|
-
});
|
|
989
|
-
return null;
|
|
990
|
-
}
|
|
991
|
-
if (source === "feed" && !schema.ingest) {
|
|
992
|
-
log.warn("collections", "feed schema.json has no `ingest` block, skipping", { slug: safeName });
|
|
993
|
-
return null;
|
|
994
|
-
}
|
|
995
|
-
const dataDir = resolveDataDir(schema.dataPath, workspaceRoot);
|
|
996
|
-
if (dataDir === null) {
|
|
997
|
-
log.warn("collections", "schema.json dataPath escapes workspace, skipping", {
|
|
1073
|
+
const acceptance = acceptParsedSchema(schema, {
|
|
1074
|
+
source,
|
|
1075
|
+
workspaceRoot
|
|
1076
|
+
});
|
|
1077
|
+
if (!acceptance.ok) {
|
|
1078
|
+
log.warn("collections", "schema.json rejected after validation, skipping", {
|
|
998
1079
|
slug: safeName,
|
|
999
|
-
|
|
1000
|
-
workspaceRoot
|
|
1080
|
+
reason: acceptance.reason
|
|
1001
1081
|
});
|
|
1002
1082
|
return null;
|
|
1003
1083
|
}
|
|
@@ -1005,7 +1085,7 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
|
|
|
1005
1085
|
slug: safeName,
|
|
1006
1086
|
source,
|
|
1007
1087
|
schema,
|
|
1008
|
-
dataDir,
|
|
1088
|
+
dataDir: acceptance.dataDir,
|
|
1009
1089
|
skillDir: path.join(skillsRoot, safeName)
|
|
1010
1090
|
};
|
|
1011
1091
|
}
|
|
@@ -1274,6 +1354,18 @@ function matchesWhen(when, schema, item) {
|
|
|
1274
1354
|
const raw = item[completionField];
|
|
1275
1355
|
return raw !== void 0 && raw !== null && completionDoneValues.includes(String(raw));
|
|
1276
1356
|
}
|
|
1357
|
+
/** Resolve the literal `every` that applies to `sourceItem`. Literal-arm
|
|
1358
|
+
* `spawn.every` passes through unchanged. Field-driven `spawn.every` reads
|
|
1359
|
+
* `sourceItem[fromField]` and looks it up in `map`; an empty field or a
|
|
1360
|
+
* value with no map entry yields null (caller skips + logs — see plan §5).
|
|
1361
|
+
* Discovery rejects a map that doesn't cover the enum's values, so null
|
|
1362
|
+
* here means a record that predates a map/enum edit. */
|
|
1363
|
+
function resolveEvery(every, sourceItem) {
|
|
1364
|
+
if (!isFieldDrivenEvery(every)) return every;
|
|
1365
|
+
const raw = sourceItem[every.fromField];
|
|
1366
|
+
if (raw === void 0 || raw === null || raw === "") return null;
|
|
1367
|
+
return every.map[String(raw)] ?? null;
|
|
1368
|
+
}
|
|
1277
1369
|
/** Build the successor record purely from (schema, source record, source
|
|
1278
1370
|
* id). Returns null when the schema has no spawn/triggerField or the
|
|
1279
1371
|
* source's trigger date is unparseable. */
|
|
@@ -1282,7 +1374,9 @@ function computeSuccessor(schema, sourceItem, sourceId) {
|
|
|
1282
1374
|
if (!spawn || !triggerField) return null;
|
|
1283
1375
|
const srcDate = parseCivil(sourceItem[triggerField]);
|
|
1284
1376
|
if (srcDate === null) return null;
|
|
1285
|
-
const
|
|
1377
|
+
const every = resolveEvery(spawn.every, sourceItem);
|
|
1378
|
+
if (every === null) return null;
|
|
1379
|
+
const next = advanceTriggerDate(srcDate, every);
|
|
1286
1380
|
const nextId = successorId(sourceId, next);
|
|
1287
1381
|
const record = {};
|
|
1288
1382
|
for (const field of spawn.carry ?? []) if (Object.prototype.hasOwnProperty.call(sourceItem, field)) record[field] = sourceItem[field];
|
|
@@ -1294,6 +1388,27 @@ function computeSuccessor(schema, sourceItem, sourceId) {
|
|
|
1294
1388
|
record
|
|
1295
1389
|
};
|
|
1296
1390
|
}
|
|
1391
|
+
/** Warn precisely about which of `computeSuccessor`'s two null causes fired
|
|
1392
|
+
* (plan §5): an unparseable source trigger date (the original cause), or a
|
|
1393
|
+
* field-driven `every` whose record value has no `map` entry (a record that
|
|
1394
|
+
* predates a map/enum edit — discovery rejects this statically otherwise). */
|
|
1395
|
+
function logSpawnSkip(slug, triggerField, every, sourceItem, sourceId) {
|
|
1396
|
+
if (parseCivil(sourceItem[triggerField]) === null) {
|
|
1397
|
+
log.warn("collections", "spawn skipped: source trigger date unparseable", {
|
|
1398
|
+
slug,
|
|
1399
|
+
sourceId,
|
|
1400
|
+
triggerField
|
|
1401
|
+
});
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
const fromField = isFieldDrivenEvery(every) ? every.fromField : void 0;
|
|
1405
|
+
log.warn("collections", "spawn skipped: no `every` mapping for frequency value", {
|
|
1406
|
+
slug,
|
|
1407
|
+
sourceId,
|
|
1408
|
+
fromField,
|
|
1409
|
+
value: fromField === void 0 ? void 0 : sourceItem[fromField]
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1297
1412
|
/** Idempotently create the successor for `sourceItem` when it matches the
|
|
1298
1413
|
* spawn predicate. No-op when the schema declares no spawn, the
|
|
1299
1414
|
* predicate doesn't match, the trigger date is unparseable, or the
|
|
@@ -1305,11 +1420,7 @@ async function maybeSpawnSuccessor(slug, schema, dataDir, sourceItem, sourceId,
|
|
|
1305
1420
|
if (!matchesWhen(spawn.when, schema, sourceItem)) return;
|
|
1306
1421
|
const computed = computeSuccessor(schema, sourceItem, sourceId);
|
|
1307
1422
|
if (computed === null) {
|
|
1308
|
-
|
|
1309
|
-
slug,
|
|
1310
|
-
sourceId,
|
|
1311
|
-
triggerField: schema.triggerField
|
|
1312
|
-
});
|
|
1423
|
+
logSpawnSkip(slug, schema.triggerField, spawn.every, sourceItem, sourceId);
|
|
1313
1424
|
return;
|
|
1314
1425
|
}
|
|
1315
1426
|
if (matchesWhen(spawn.when, schema, computed.record)) {
|
|
@@ -1571,6 +1682,6 @@ async function deleteCustomView(collection, viewId, opts = {}) {
|
|
|
1571
1682
|
};
|
|
1572
1683
|
}
|
|
1573
1684
|
//#endregion
|
|
1574
|
-
export { COMPUTED_TYPES, CollectionSchemaZ, SCHEMA_FILE, advanceTriggerDate, buildActionSeedPrompt, buildCollectionActionSeedPrompt, computeSuccessor, configureCollectionHost, daysInMonth, deleteCollection, deleteCollectionRefusalMessage, deleteCustomView, deleteItem, discoverCollections, enrichItems, formatCivil, generateItemId, getWorkspaceRoot, isContainedInRoot, isContainedInWorkspace, isSafeActionTemplatePath, isSafeCustomViewPath, isSafeTemplatePath, isTriggerDue, itemFilePath, listItems, loadCollection, log, maybeSpawnSuccessor, parseCivil, readCustomViewHtml, readItem, readSkillTemplate, resolveCreateItemId, resolveDataDir, resolveTemplatePath, safeSlugName, successorId, toDetail, toSummary, validateCollectionRecords, validateRecordObject, writeItem };
|
|
1685
|
+
export { COMPUTED_TYPES, CollectionSchemaZ, SCHEMA_FILE, acceptParsedSchema, advanceTriggerDate, buildActionSeedPrompt, buildCollectionActionSeedPrompt, computeSuccessor, configureCollectionHost, daysInMonth, deleteCollection, deleteCollectionRefusalMessage, deleteCustomView, deleteItem, discoverCollections, enrichItems, formatCivil, generateItemId, getWorkspaceRoot, isContainedInRoot, isContainedInWorkspace, isSafeActionTemplatePath, isSafeCustomViewPath, isSafeTemplatePath, isTriggerDue, itemFilePath, listItems, loadCollection, log, maybeSpawnSuccessor, parseCivil, readCustomViewHtml, readItem, readSkillTemplate, resolveCreateItemId, resolveDataDir, resolveEvery, resolveTemplatePath, safeRecordId, safeSlugName, successorId, toDetail, toSummary, validateCollectionRecords, validateRecordObject, writeItem };
|
|
1575
1686
|
|
|
1576
1687
|
//# sourceMappingURL=server.js.map
|