slidemind 0.3.20 → 0.4.1
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +592 -16
- package/LICENSE +4 -0
- package/README.md +203 -8
- package/THIRD-PARTY-NOTICES.md +201 -0
- package/bin/slidemind.mjs +50 -2
- package/package.json +5 -3
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The operation-scope gate, CLI half (workflow/edit.md → "This is not a
|
|
2
|
+
// text-only cage"). The add-in refuses these transitions at the write
|
|
3
|
+
// (`edit-transitions.ts`); this CLI's agent writes slide files with its own
|
|
4
|
+
// tools, so the first moment slidemind sees a change is the next command that
|
|
5
|
+
// reads the deck — view-draft, build-pptx, push. The RULE is shared
|
|
6
|
+
// (`editTransitionViolation`, src/lib/edit-operations.mjs, mirrored into the
|
|
7
|
+
// add-in); this module only reads the deck to ask it.
|
|
8
|
+
//
|
|
9
|
+
// One write it makes, and only for an authorized redesign: an imported slide
|
|
10
|
+
// whose pragma a redesign dropped is recorded as restyled (`dialect:
|
|
11
|
+
// "semantic"`, back to `needsReview`) — the counterpart of the add-in's
|
|
12
|
+
// dialect-sync. Without it the render keeps reading the record as converted,
|
|
13
|
+
// and every later edit of the restyled slide would read as a dropped pragma.
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
import { CONVERSIONS_DIR, conversionProvenance } from "./conversions.mjs";
|
|
19
|
+
import { deckSlug } from "./deck-paths.mjs";
|
|
20
|
+
import { editTransitionViolation, operationSlideKeys } from "./edit-operations.mjs";
|
|
21
|
+
import { openOperations, resolveSlideTargets } from "./edit-state.mjs";
|
|
22
|
+
|
|
23
|
+
// The renderer's own test: the pragma is the source's FIRST line.
|
|
24
|
+
const PRAGMA_RE = /^\s*\/\/\s*SLIDEMIND_DIALECT:\s*converted\s*$/;
|
|
25
|
+
const keepsPragma = (jsx) => PRAGMA_RE.test(String(jsx).split("\n", 1)[0] ?? "");
|
|
26
|
+
|
|
27
|
+
function readSource(deckDir, jsxPath) {
|
|
28
|
+
try {
|
|
29
|
+
return fs.readFileSync(path.join(deckDir, jsxPath), "utf8");
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function markRestyled(deckDir, file, record) {
|
|
36
|
+
const stem = file.replace(/\.jsx$/i, "");
|
|
37
|
+
const next = { ...record, dialect: "semantic", state: "needsReview" };
|
|
38
|
+
fs.writeFileSync(path.join(deckDir, CONVERSIONS_DIR, `${stem}.json`), `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every change on disk that the open scopes do not authorize, as refusal lines
|
|
43
|
+
* (empty when the deck may proceed). A record that cannot be read refuses: what
|
|
44
|
+
* this session may change cannot be established.
|
|
45
|
+
*/
|
|
46
|
+
export function editTransitionRefusals(deckDir) {
|
|
47
|
+
let operations;
|
|
48
|
+
try {
|
|
49
|
+
operations = openOperations(deckDir);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return [`${err.message} What this session may change cannot be established, so nothing was rendered.`];
|
|
52
|
+
}
|
|
53
|
+
// Nothing scoped and nothing imported: no transition to ask about.
|
|
54
|
+
if (operations.length === 0 && !fs.existsSync(path.join(deckDir, CONVERSIONS_DIR))) return [];
|
|
55
|
+
const refusals = [];
|
|
56
|
+
const slides = resolveSlideTargets(deckDir);
|
|
57
|
+
// Scopes are compared by slide key, never by path: a reorder renames every
|
|
58
|
+
// file, and a renamed target is still the slide the scope named.
|
|
59
|
+
const scoped = operations.filter((op) => op.kind !== "create");
|
|
60
|
+
const inScope = scoped.length ? [...new Set(scoped.flatMap((op) => operationSlideKeys(op)))] : null;
|
|
61
|
+
const deck = deckSlug(deckDir);
|
|
62
|
+
|
|
63
|
+
// Another slide, or a sibling beside a target, changed while a scope is open.
|
|
64
|
+
for (const slide of slides) {
|
|
65
|
+
if (!editTransitionViolation({ scoped: inScope }, { slide: slide.slideKey })) continue;
|
|
66
|
+
const op = scoped.find((o) => (o.preparedSourceHashes ?? {})[slide.slideKey] !== slide.sourceHash);
|
|
67
|
+
if (!op) continue;
|
|
68
|
+
const before = (op.preparedSourceHashes ?? {})[slide.slideKey];
|
|
69
|
+
refusals.push(
|
|
70
|
+
`${slide.jsxPath} ${before === undefined ? "appeared" : "changed"} after the ${op.kind} scope ` +
|
|
71
|
+
`${op.operationId} opened on ${op.targets.map((t) => t.jsxPath).join(", ")}. A scope authorizes its own slides only — a ` +
|
|
72
|
+
`second file beside a target does not become the edit, it becomes an extra slide nobody asked for. Undo it ` +
|
|
73
|
+
`and make the change in the target itself; if the request covers this slide too, finish the session ` +
|
|
74
|
+
`(\`slidemind revision finish ${deck}\`) and open a scope that names it.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// An imported slide re-authored without its pragma: a restyle, so a redesign.
|
|
79
|
+
for (const slide of slides) {
|
|
80
|
+
if (!/\.jsx$/i.test(slide.file)) continue;
|
|
81
|
+
const record = conversionProvenance(deckDir, slide.file);
|
|
82
|
+
if (!record || (record.dialect ?? "converted") !== "converted") continue;
|
|
83
|
+
const jsx = readSource(deckDir, slide.jsxPath);
|
|
84
|
+
if (jsx === null || keepsPragma(jsx)) continue;
|
|
85
|
+
const op = operations.find((o) => (o.targets ?? []).some((t) => t?.slideKey === slide.slideKey)) ?? null;
|
|
86
|
+
const kind = op?.kind ?? null;
|
|
87
|
+
if (!editTransitionViolation({ kind }, { slide: slide.slideKey, imported: true, keepsPragma: false })) {
|
|
88
|
+
markRestyled(deckDir, slide.file, record);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
refusals.push(
|
|
92
|
+
`${slide.jsxPath} is an imported slide and its source no longer starts with ` +
|
|
93
|
+
`\`// SLIDEMIND_DIALECT: converted\` — that restyles the import into brand jsx, which is a redesign. ` +
|
|
94
|
+
(op ? `Its scope ${op.operationId} is a ${kind}` : `No redesign scope names it`) +
|
|
95
|
+
`, so the change is not authorized, and deleting the line does not authorize it: the import is recorded ` +
|
|
96
|
+
`in ${CONVERSIONS_DIR}/${slide.file.replace(/\.jsx$/i, "")}.json. Restore the pragma as the first line and ` +
|
|
97
|
+
`make the change inside the imported source. A restyle the user asked for runs as ` +
|
|
98
|
+
`\`slidemind revision begin ${deck} --operation redesign --slides ${slide.number}\`.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return refusals;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The slide files the open scopes authorize, or null when none is open — what
|
|
106
|
+
* bounds slidemind's own rewrites (view-draft's autofix) to the same slides.
|
|
107
|
+
*/
|
|
108
|
+
export function scopedSlideFiles(deckDir) {
|
|
109
|
+
let operations;
|
|
110
|
+
try {
|
|
111
|
+
operations = openOperations(deckDir).filter((op) => op.kind !== "create");
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
if (operations.length === 0) return null;
|
|
116
|
+
const keys = new Set(operations.flatMap((op) => operationSlideKeys(op)));
|
|
117
|
+
return resolveSlideTargets(deckDir)
|
|
118
|
+
.filter((t) => keys.has(t.slideKey) && /\.jsx$/i.test(t.file))
|
|
119
|
+
.map((t) => t.file);
|
|
120
|
+
}
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// Trusted installed extensions — protocol v1.
|
|
2
|
+
//
|
|
3
|
+
// A deck can carry business rules the generic CLI has no business knowing:
|
|
4
|
+
// which coverage a deck must show, which commercial figures have to agree,
|
|
5
|
+
// which approved plan its content was signed off against. Those rules live in a
|
|
6
|
+
// privately distributed pack, and this is the wire between the two.
|
|
7
|
+
//
|
|
8
|
+
// The single security property everything else follows from: **the deck selects
|
|
9
|
+
// an extension by id; it never supplies what to run.** Registration is local and
|
|
10
|
+
// deliberate — a file this machine's user installed — so a mailed .pptx can name
|
|
11
|
+
// `some-pack` and get nothing at all on a machine that does not have it
|
|
12
|
+
// (a `extension_missing` blocker, and no managed publish), rather than executing
|
|
13
|
+
// whatever a tag said.
|
|
14
|
+
//
|
|
15
|
+
// Five methods, JSON in on stdin, JSON out on stdout, one process per call:
|
|
16
|
+
//
|
|
17
|
+
// hydrate cold open: restore local context from the portable refs, and say
|
|
18
|
+
// which slides need to be editable for the work ahead.
|
|
19
|
+
// manifest which durable artifacts this deck owns right now — purpose,
|
|
20
|
+
// requiredness, mutability, content hash, where they sit locally.
|
|
21
|
+
// assess incoming changes landed; does the business side accept them?
|
|
22
|
+
// validate the FINAL candidate bytes plus the final dependency refs. Returns
|
|
23
|
+
// hash-bound evidence, which is what makes a later reuse legitimate.
|
|
24
|
+
// committed a confirmed receipt, idempotently. Local state only — an
|
|
25
|
+
// extension may not publish or build from here.
|
|
26
|
+
//
|
|
27
|
+
// The CLI stays purpose-agnostic on purpose (AGENTS.md: this package must never
|
|
28
|
+
// carry, name or hardcode anything from a pack). `purpose` is an opaque string
|
|
29
|
+
// used for pairing and requiredness; what the CLI DOES enforce is containment —
|
|
30
|
+
// a declared local path that escapes the deck, traverses a symlink, or lands on
|
|
31
|
+
// the revision protocol's own state is refused and diverted to isolated recovery
|
|
32
|
+
// storage, whatever the purpose was called.
|
|
33
|
+
|
|
34
|
+
import { spawnSync } from "node:child_process";
|
|
35
|
+
import { createHash } from "node:crypto";
|
|
36
|
+
import fs from "node:fs";
|
|
37
|
+
import path from "node:path";
|
|
38
|
+
|
|
39
|
+
import { deckRoot, deckSlug } from "./deck-paths.mjs";
|
|
40
|
+
import { sessionConfigDir } from "../bridge/config.mjs";
|
|
41
|
+
|
|
42
|
+
export const PROTOCOL = "slidemind.extension";
|
|
43
|
+
export const PROTOCOL_VERSION = 1;
|
|
44
|
+
export const METHODS = ["hydrate", "manifest", "assess", "validate", "committed"];
|
|
45
|
+
|
|
46
|
+
/** Every status an extension may answer with. Anything else is read as `error`. */
|
|
47
|
+
export const STATUSES = ["ok", "blocked", "unsupported", "error"];
|
|
48
|
+
|
|
49
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
50
|
+
// A response bigger than this is a bug on the other side, not a manifest.
|
|
51
|
+
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
52
|
+
|
|
53
|
+
const sha256File = (file) =>
|
|
54
|
+
createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
55
|
+
|
|
56
|
+
function envTimeoutMs() {
|
|
57
|
+
const n = Number(process.env.SLIDEMIND_EXTENSION_TIMEOUT_MS);
|
|
58
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TIMEOUT_MS;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// registration
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Where the machine's trusted registrations live.
|
|
67
|
+
*
|
|
68
|
+
* `SLIDEMIND_EXTENSIONS_FILE` overrides it — the test seam, and the way an
|
|
69
|
+
* admin-managed harness points at a read-only registry it controls.
|
|
70
|
+
*/
|
|
71
|
+
export function registryPath() {
|
|
72
|
+
return (
|
|
73
|
+
process.env.SLIDEMIND_EXTENSIONS_FILE ||
|
|
74
|
+
path.join(sessionConfigDir(), "extensions.json")
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The registry: `{ version: 1, extensions: { "<id>": { command, protocolVersions,
|
|
80
|
+
* dataVersions, label } } }`.
|
|
81
|
+
*
|
|
82
|
+
* `command` is an argv ARRAY, never a shell string: a string would be parsed by
|
|
83
|
+
* a shell, and a registry entry is exactly the place where that turns into
|
|
84
|
+
* command injection with a straight face.
|
|
85
|
+
*/
|
|
86
|
+
export function readRegistry() {
|
|
87
|
+
let raw;
|
|
88
|
+
try {
|
|
89
|
+
raw = JSON.parse(fs.readFileSync(registryPath(), "utf8"));
|
|
90
|
+
} catch {
|
|
91
|
+
return { version: 1, extensions: {} };
|
|
92
|
+
}
|
|
93
|
+
if (raw?.version !== 1 || !raw.extensions || typeof raw.extensions !== "object") {
|
|
94
|
+
return { version: 1, extensions: {} };
|
|
95
|
+
}
|
|
96
|
+
const extensions = {};
|
|
97
|
+
for (const [id, entry] of Object.entries(raw.extensions)) {
|
|
98
|
+
if (!Array.isArray(entry?.command) || entry.command.length === 0) continue;
|
|
99
|
+
if (entry.command.some((a) => typeof a !== "string")) continue;
|
|
100
|
+
extensions[id] = {
|
|
101
|
+
command: entry.command,
|
|
102
|
+
protocolVersions: Array.isArray(entry.protocolVersions) ? entry.protocolVersions : [1],
|
|
103
|
+
dataVersions: Array.isArray(entry.dataVersions) ? entry.dataVersions : null,
|
|
104
|
+
label: typeof entry.label === "string" ? entry.label : id,
|
|
105
|
+
// Optional workspace adapter (protocol `slidemind.workspace-adapter`, a
|
|
106
|
+
// separate wire from this one — src/workspace/adapters.mjs). Same rule:
|
|
107
|
+
// an argv array from THIS registry, never from a deck.
|
|
108
|
+
workspace: workspaceEntry(entry.workspace),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return { version: 1, extensions };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function workspaceEntry(raw) {
|
|
115
|
+
if (!raw || typeof raw !== "object") return null;
|
|
116
|
+
if (!Array.isArray(raw.command) || raw.command.length === 0) return null;
|
|
117
|
+
if (raw.command.some((a) => typeof a !== "string")) return null;
|
|
118
|
+
const versions = Array.isArray(raw.protocolVersions)
|
|
119
|
+
? raw.protocolVersions.filter((v) => Number.isInteger(v))
|
|
120
|
+
: [1];
|
|
121
|
+
return { command: raw.command, protocolVersions: versions };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve one declaration from a deck's context against what is installed here.
|
|
126
|
+
*
|
|
127
|
+
* Returns `{ id, entry }` when it is usable, or `{ id, blocker }` naming exactly
|
|
128
|
+
* which of the two failures it is — "you do not have this pack" and "your pack
|
|
129
|
+
* is too old for this deck" need different remedies, and collapsing them into
|
|
130
|
+
* one message has sent people to reinstall something that was already there.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveExtension(declaration, registry = readRegistry()) {
|
|
133
|
+
const id = declaration?.id;
|
|
134
|
+
if (!id) return { id: null, blocker: { code: "extension_incompatible", message: "an extension declaration carries no id" } };
|
|
135
|
+
const entry = registry.extensions[id];
|
|
136
|
+
if (!entry) {
|
|
137
|
+
return {
|
|
138
|
+
id,
|
|
139
|
+
blocker: {
|
|
140
|
+
code: "extension_missing",
|
|
141
|
+
message:
|
|
142
|
+
`this deck requires the '${id}' extension, which is not installed on this machine. ` +
|
|
143
|
+
`Inspection and local recovery still work; a managed publish does not.`,
|
|
144
|
+
actor: "user",
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const wantProtocol = declaration.protocolVersion ?? PROTOCOL_VERSION;
|
|
149
|
+
if (!entry.protocolVersions.includes(wantProtocol)) {
|
|
150
|
+
return {
|
|
151
|
+
id,
|
|
152
|
+
blocker: {
|
|
153
|
+
code: "extension_incompatible",
|
|
154
|
+
message:
|
|
155
|
+
`this deck speaks '${id}' protocol v${wantProtocol}; the installed copy speaks ` +
|
|
156
|
+
`v${entry.protocolVersions.join(", v")}. Update it before publishing.`,
|
|
157
|
+
actor: "user",
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (
|
|
162
|
+
declaration.dataVersion !== undefined &&
|
|
163
|
+
entry.dataVersions &&
|
|
164
|
+
!entry.dataVersions.includes(declaration.dataVersion)
|
|
165
|
+
) {
|
|
166
|
+
return {
|
|
167
|
+
id,
|
|
168
|
+
blocker: {
|
|
169
|
+
code: "extension_incompatible",
|
|
170
|
+
message:
|
|
171
|
+
`this deck carries '${id}' data version ${declaration.dataVersion}; the installed copy ` +
|
|
172
|
+
`reads ${entry.dataVersions.join(", ")}.`,
|
|
173
|
+
actor: "user",
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return { id, entry };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// the call
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* One request envelope. Built here rather than by each caller so every method
|
|
186
|
+
* carries the same evidence fields and the pack has one shape to parse.
|
|
187
|
+
*/
|
|
188
|
+
export function buildRequest({
|
|
189
|
+
method,
|
|
190
|
+
workspaceDir,
|
|
191
|
+
operationId = null,
|
|
192
|
+
declaration = null,
|
|
193
|
+
revision = null,
|
|
194
|
+
dependencies = [],
|
|
195
|
+
context = null,
|
|
196
|
+
candidate = null,
|
|
197
|
+
incoming = null,
|
|
198
|
+
receipt = null,
|
|
199
|
+
}) {
|
|
200
|
+
return {
|
|
201
|
+
protocol: PROTOCOL,
|
|
202
|
+
version: PROTOCOL_VERSION,
|
|
203
|
+
method,
|
|
204
|
+
operationId,
|
|
205
|
+
extensionId: declaration?.id ?? null,
|
|
206
|
+
dataVersion: declaration?.dataVersion ?? null,
|
|
207
|
+
deck: {
|
|
208
|
+
workspace: workspaceDir,
|
|
209
|
+
root: deckRoot(workspaceDir),
|
|
210
|
+
slug: deckSlug(workspaceDir),
|
|
211
|
+
},
|
|
212
|
+
revision,
|
|
213
|
+
dependencies,
|
|
214
|
+
context,
|
|
215
|
+
candidate,
|
|
216
|
+
incoming,
|
|
217
|
+
receipt,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Run one method. Never throws for the other side's failures — a crashed,
|
|
223
|
+
* timed-out or unparseable extension comes back as `status: "error"` with a
|
|
224
|
+
* typed blocker, because the caller's job is to decide whether that blocks a
|
|
225
|
+
* publish, not to unwind a stack.
|
|
226
|
+
*/
|
|
227
|
+
export function callExtension(entry, method, request, { timeoutMs = null } = {}) {
|
|
228
|
+
if (!METHODS.includes(method)) throw new Error(`unknown extension method: ${method}`);
|
|
229
|
+
const [cmd, ...prefix] = entry.command;
|
|
230
|
+
const result = spawnSync(cmd, [...prefix, method, "--json"], {
|
|
231
|
+
input: JSON.stringify(request),
|
|
232
|
+
encoding: "utf8",
|
|
233
|
+
maxBuffer: MAX_RESPONSE_BYTES,
|
|
234
|
+
timeout: timeoutMs ?? envTimeoutMs(),
|
|
235
|
+
// No shell, ever: `command` is argv, and the whole trust model depends on it
|
|
236
|
+
// staying argv.
|
|
237
|
+
shell: false,
|
|
238
|
+
});
|
|
239
|
+
const fail = (message) => ({
|
|
240
|
+
protocol: PROTOCOL,
|
|
241
|
+
version: PROTOCOL_VERSION,
|
|
242
|
+
method,
|
|
243
|
+
status: "error",
|
|
244
|
+
blockers: [{ code: "extension_incompatible", message, actor: "user" }],
|
|
245
|
+
});
|
|
246
|
+
if (result.error) {
|
|
247
|
+
return fail(
|
|
248
|
+
`the '${request.extensionId ?? "installed"}' extension could not be run (${result.error.code ?? result.error.message}).`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (result.status !== 0) {
|
|
252
|
+
const detail = String(result.stderr ?? "").trim().split("\n").slice(-1)[0] ?? "";
|
|
253
|
+
return fail(
|
|
254
|
+
`the '${request.extensionId ?? "installed"}' extension exited ${result.status}${detail ? `: ${detail}` : ""}`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
let parsed;
|
|
258
|
+
try {
|
|
259
|
+
parsed = JSON.parse(String(result.stdout ?? ""));
|
|
260
|
+
} catch {
|
|
261
|
+
return fail(
|
|
262
|
+
`the '${request.extensionId ?? "installed"}' extension did not answer ${method} with JSON.`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return normalizeResponse(parsed, method);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Fill in the fields a lenient implementation may omit, and reject a wrong shape. */
|
|
269
|
+
export function normalizeResponse(parsed, method) {
|
|
270
|
+
const status = STATUSES.includes(parsed?.status) ? parsed.status : "error";
|
|
271
|
+
const blockers = (Array.isArray(parsed?.blockers) ? parsed.blockers : [])
|
|
272
|
+
.filter((b) => b && typeof b.code === "string")
|
|
273
|
+
.map((b) => ({
|
|
274
|
+
code: b.code,
|
|
275
|
+
message: typeof b.message === "string" ? b.message : b.code,
|
|
276
|
+
actor: b.actor === "agent" ? "agent" : "user",
|
|
277
|
+
detail: b.detail ?? null,
|
|
278
|
+
}));
|
|
279
|
+
return {
|
|
280
|
+
protocol: PROTOCOL,
|
|
281
|
+
version: Number(parsed?.version) === PROTOCOL_VERSION ? PROTOCOL_VERSION : parsed?.version ?? null,
|
|
282
|
+
method,
|
|
283
|
+
status,
|
|
284
|
+
dataVersion: parsed?.dataVersion ?? null,
|
|
285
|
+
blockers:
|
|
286
|
+
status === "ok" || blockers.length
|
|
287
|
+
? blockers
|
|
288
|
+
: [
|
|
289
|
+
{
|
|
290
|
+
code: status === "unsupported" ? "extension_incompatible" : "validation_required",
|
|
291
|
+
message: typeof parsed?.message === "string" ? parsed.message : `${method} did not pass`,
|
|
292
|
+
actor: "user",
|
|
293
|
+
detail: null,
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
artifacts: Array.isArray(parsed?.artifacts) ? parsed.artifacts : [],
|
|
297
|
+
hydrate: parsed?.hydrate ?? null,
|
|
298
|
+
context: parsed?.context ?? null,
|
|
299
|
+
contextDigest: typeof parsed?.contextDigest === "string" ? parsed.contextDigest : null,
|
|
300
|
+
evidence: parsed?.evidence ?? null,
|
|
301
|
+
message: typeof parsed?.message === "string" ? parsed.message : null,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// containment — where a declared artifact may and may not live
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
// Executable suffixes: an artifact that lands on one of these is a program this
|
|
310
|
+
// machine might later run, which is not what "the deck's approved workbook" is.
|
|
311
|
+
const EXECUTABLE = /\.(mjs|cjs|js|ts|sh|bash|zsh|ps1|psm1|bat|cmd|com|exe|dll|dylib|so|py|rb|pl|scpt|app)$/i;
|
|
312
|
+
|
|
313
|
+
// Directories the CLI's own protocols own. An extension writing here could
|
|
314
|
+
// forge an accepted base, a receipt or an owner token.
|
|
315
|
+
const RESERVED_DIRS = ["state", "slides", "conversions"];
|
|
316
|
+
|
|
317
|
+
/** Isolated storage for anything that failed containment — never overwrites, never runs. */
|
|
318
|
+
export function recoveryDir(workspaceDir, extensionId) {
|
|
319
|
+
return path.join(workspaceDir, "state", "recovery", sanitizeSegment(extensionId));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function sanitizeSegment(value) {
|
|
323
|
+
return String(value ?? "unknown").replace(/[^A-Za-z0-9._-]/g, "-").replace(/^\.+/, "") || "unknown";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// A path is inside `root` only if every step of the way is a real directory, not
|
|
327
|
+
// a symlink: resolving the final path alone would happily follow a symlink that
|
|
328
|
+
// points out of the deck and report a contained answer.
|
|
329
|
+
function traversesSymlink(root, target) {
|
|
330
|
+
let current = path.resolve(target);
|
|
331
|
+
const stop = path.resolve(root);
|
|
332
|
+
while (current !== stop && current.startsWith(stop)) {
|
|
333
|
+
try {
|
|
334
|
+
if (fs.lstatSync(current).isSymbolicLink()) return true;
|
|
335
|
+
} catch {
|
|
336
|
+
/* not there yet — a path that does not exist cannot be a symlink */
|
|
337
|
+
}
|
|
338
|
+
const parent = path.dirname(current);
|
|
339
|
+
if (parent === current) break;
|
|
340
|
+
current = parent;
|
|
341
|
+
}
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Where an artifact the extension declared may actually live, and why not.
|
|
347
|
+
*
|
|
348
|
+
* Returns `{ ok: true, path }` or `{ ok: false, why, recovery }` — the second
|
|
349
|
+
* form still carries a usable destination, because a refused placement must not
|
|
350
|
+
* lose the bytes; it isolates them.
|
|
351
|
+
*/
|
|
352
|
+
export function resolveArtifactPath(workspaceDir, artifact) {
|
|
353
|
+
const root = path.resolve(deckRoot(workspaceDir));
|
|
354
|
+
const recovery = path.join(
|
|
355
|
+
recoveryDir(workspaceDir, artifact?.extensionId ?? "unknown"),
|
|
356
|
+
sanitizeSegment(artifact?.artifactId),
|
|
357
|
+
);
|
|
358
|
+
const raw = artifact?.localPath;
|
|
359
|
+
if (typeof raw !== "string" || !raw) {
|
|
360
|
+
return { ok: false, why: "the artifact declares no localPath", recovery };
|
|
361
|
+
}
|
|
362
|
+
if (path.isAbsolute(raw)) {
|
|
363
|
+
return { ok: false, why: "an artifact's localPath must be relative to the deck", recovery };
|
|
364
|
+
}
|
|
365
|
+
const target = path.resolve(workspaceDir, raw);
|
|
366
|
+
if (target !== root && !target.startsWith(root + path.sep)) {
|
|
367
|
+
return { ok: false, why: "it resolves outside the deck", recovery };
|
|
368
|
+
}
|
|
369
|
+
const relToWorkspace = path.relative(workspaceDir, target).split(path.sep);
|
|
370
|
+
if (RESERVED_DIRS.includes(relToWorkspace[0])) {
|
|
371
|
+
return {
|
|
372
|
+
ok: false,
|
|
373
|
+
why: `\`${relToWorkspace[0]}/\` belongs to the CLI's own revision protocol`,
|
|
374
|
+
recovery,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
if (EXECUTABLE.test(target)) {
|
|
378
|
+
return { ok: false, why: "an artifact may not be an executable file", recovery };
|
|
379
|
+
}
|
|
380
|
+
if (traversesSymlink(root, target)) {
|
|
381
|
+
return { ok: false, why: "its path traverses a symlink", recovery };
|
|
382
|
+
}
|
|
383
|
+
return { ok: true, path: target };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Read a manifest's artifacts into the shape the publisher works with: one entry
|
|
388
|
+
* per artifact with its resolved local file, its hash as it is on disk RIGHT NOW,
|
|
389
|
+
* and whether it may be uploaded.
|
|
390
|
+
*
|
|
391
|
+
* This is also the upload allowlist. Only files an extension DECLARED are
|
|
392
|
+
* candidates — never a glob over the workspace, never a recursive walk. A deck
|
|
393
|
+
* directory holds the user's scratch, their source corpus and whatever else they
|
|
394
|
+
* dropped in; uploading it because a manifest said "the deck" would be a data
|
|
395
|
+
* leak with a progress bar.
|
|
396
|
+
*/
|
|
397
|
+
export function collectManifest(workspaceDir, response, { extensionId }) {
|
|
398
|
+
const artifacts = [];
|
|
399
|
+
const problems = [];
|
|
400
|
+
for (const declared of response.artifacts ?? []) {
|
|
401
|
+
// The declaring extension wins: `extensionId` here is only the fallback for
|
|
402
|
+
// a single-extension call that did not tag its own artifacts.
|
|
403
|
+
const artifact = { ...declared, extensionId: declared.extensionId ?? extensionId };
|
|
404
|
+
const placement = resolveArtifactPath(workspaceDir, artifact);
|
|
405
|
+
if (!placement.ok) {
|
|
406
|
+
problems.push(`${artifact.artifactId ?? "(unnamed)"}: ${placement.why}`);
|
|
407
|
+
artifacts.push({ ...artifact, uploadable: false, refused: placement.why, recovery: placement.recovery });
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (!fs.existsSync(placement.path)) {
|
|
411
|
+
problems.push(`${artifact.artifactId ?? "(unnamed)"}: ${declared.localPath} is not on disk`);
|
|
412
|
+
artifacts.push({ ...artifact, uploadable: false, refused: "missing", file: placement.path });
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const digest = sha256File(placement.path);
|
|
416
|
+
if (artifact.sha256 && artifact.sha256 !== digest) {
|
|
417
|
+
// Declared hash vs. bytes on disk. The bytes win for uploading, but the
|
|
418
|
+
// disagreement is reported: an extension that hashed a file it then
|
|
419
|
+
// rewrote is about to pair the deck with content nobody checked.
|
|
420
|
+
problems.push(
|
|
421
|
+
`${artifact.artifactId}: declared sha256 does not match the file on disk`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
artifacts.push({
|
|
425
|
+
...artifact,
|
|
426
|
+
file: placement.path,
|
|
427
|
+
sha256: digest,
|
|
428
|
+
bytes: fs.statSync(placement.path).size,
|
|
429
|
+
uploadable: true,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
return { artifacts, problems };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Write a hydrated artifact into the workspace, or into isolated recovery
|
|
437
|
+
* storage when it fails containment. Never overwrites a file whose bytes already
|
|
438
|
+
* match — identical durable content is reused, so a hydrate does not churn the
|
|
439
|
+
* deck's dirty set.
|
|
440
|
+
*/
|
|
441
|
+
export function materializeArtifact(workspaceDir, artifact, bytes) {
|
|
442
|
+
const placement = resolveArtifactPath(workspaceDir, artifact);
|
|
443
|
+
const target = placement.ok ? placement.path : placement.recovery;
|
|
444
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
445
|
+
if (artifact.sha256 && artifact.sha256 !== digest) {
|
|
446
|
+
const e = new Error(
|
|
447
|
+
`${artifact.artifactId}: the bytes fetched do not hash to the ref's sha256 — not written`,
|
|
448
|
+
);
|
|
449
|
+
e.code = "artifact_hash_mismatch";
|
|
450
|
+
throw e;
|
|
451
|
+
}
|
|
452
|
+
let existing = null;
|
|
453
|
+
try {
|
|
454
|
+
existing = sha256File(target);
|
|
455
|
+
} catch {
|
|
456
|
+
/* not there */
|
|
457
|
+
}
|
|
458
|
+
if (existing === digest) return { path: target, written: false, isolated: !placement.ok };
|
|
459
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
460
|
+
fs.writeFileSync(target, bytes);
|
|
461
|
+
return {
|
|
462
|
+
path: target,
|
|
463
|
+
written: true,
|
|
464
|
+
isolated: !placement.ok,
|
|
465
|
+
why: placement.ok ? null : placement.why,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// the orchestration one caller does over several extensions
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Run one method across every extension a context declares.
|
|
475
|
+
*
|
|
476
|
+
* Returns `{ results, blockers, missing }`. A REQUIRED extension that is missing
|
|
477
|
+
* or incompatible contributes a blocker; an optional one is simply skipped —
|
|
478
|
+
* inspection and local recovery must keep working on a machine that does not
|
|
479
|
+
* have every pack a deck has ever met.
|
|
480
|
+
*/
|
|
481
|
+
export function runDeclared(workspaceDir, declarations, method, requestFields = {}) {
|
|
482
|
+
const registry = readRegistry();
|
|
483
|
+
const results = [];
|
|
484
|
+
const blockers = [];
|
|
485
|
+
const missing = [];
|
|
486
|
+
for (const declaration of declarations ?? []) {
|
|
487
|
+
const { id, entry, blocker } = resolveExtension(declaration, registry);
|
|
488
|
+
if (!entry) {
|
|
489
|
+
missing.push({ id, required: declaration?.required !== false, blocker });
|
|
490
|
+
if (declaration?.required !== false) blockers.push({ ...blocker, extensionId: id });
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const request = buildRequest({ method, workspaceDir, declaration, ...requestFields });
|
|
494
|
+
const response = callExtension(entry, method, request);
|
|
495
|
+
results.push({ id, declaration, request, response });
|
|
496
|
+
if (response.status !== "ok" && declaration?.required !== false) {
|
|
497
|
+
blockers.push(...response.blockers.map((b) => ({ ...b, extensionId: id })));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return { results, blockers, missing };
|
|
501
|
+
}
|