slidemind 0.3.19 → 0.4.0
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 +591 -15
- package/README.md +202 -7
- package/bin/slidemind.mjs +50 -2
- package/package.json +2 -2
- 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/engine-slides.mjs +1 -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,818 @@
|
|
|
1
|
+
import { stageWorkspace } from '../lib/workspace-transaction.mjs';
|
|
2
|
+
import { runDeclared } from '../lib/extensions.mjs';
|
|
3
|
+
// `slidemind open | pull | publish | rename` — the user-facing half of the
|
|
4
|
+
// SharePoint pull/publish protocol (src/lib/publish.mjs).
|
|
5
|
+
//
|
|
6
|
+
// This module owns arguments, credentials and printing. Every decision that
|
|
7
|
+
// could lose work lives in `src/lib/publish.mjs`, so the suite can drive it
|
|
8
|
+
// against a mock provider without a tenant, a token or a network.
|
|
9
|
+
//
|
|
10
|
+
// Two things are deliberately loud here rather than quiet:
|
|
11
|
+
//
|
|
12
|
+
// • **A switched-off deployment says so.** Every write route is live (doc 20 →
|
|
13
|
+
// Routes); what still stops a write is the add-in's `sharepointPublish`
|
|
14
|
+
// policy, and then `publish` refuses with `remote_saving_disabled` and names
|
|
15
|
+
// `--manual` rather than quietly preparing a manual upload.
|
|
16
|
+
// • **"Saved" is only ever printed for a confirmed receipt.** Staged, blocked,
|
|
17
|
+
// pending-manual and unknown-outcome each get their own word, because the
|
|
18
|
+
// one thing a user must never have to guess is whether their colleague can
|
|
19
|
+
// now see their edits.
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
fail,
|
|
27
|
+
resolveDeckArg,
|
|
28
|
+
resolveDeckRoot,
|
|
29
|
+
deckSlug,
|
|
30
|
+
splitDeckArg,
|
|
31
|
+
} from "./common.mjs";
|
|
32
|
+
import { acquireOwner, readRevision, revisionStatus } from "../lib/revision.mjs";
|
|
33
|
+
import {
|
|
34
|
+
bindRemote,
|
|
35
|
+
computeMode,
|
|
36
|
+
detachRemote,
|
|
37
|
+
documentInstance,
|
|
38
|
+
pendingOperations,
|
|
39
|
+
readRemote,
|
|
40
|
+
savedDestination,
|
|
41
|
+
writeRemote,
|
|
42
|
+
} from "../lib/remote.mjs";
|
|
43
|
+
import {
|
|
44
|
+
checkFreshness,
|
|
45
|
+
classifyInstance,
|
|
46
|
+
classifyOrdinary,
|
|
47
|
+
contextOfBytes,
|
|
48
|
+
declaredExtensions,
|
|
49
|
+
hydrateContext,
|
|
50
|
+
publishDeck,
|
|
51
|
+
pullDeck,
|
|
52
|
+
recoverOperations,
|
|
53
|
+
renameBound,
|
|
54
|
+
} from "../lib/publish.mjs";
|
|
55
|
+
|
|
56
|
+
const json = (data) => process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
57
|
+
const note = (line) => process.stderr.write(` ${line}\n`);
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// credentials and the provider
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Everything a remote command needs: which deployment, its SharePoint origin,
|
|
65
|
+
* the user's own delegated token, and the provider module.
|
|
66
|
+
*
|
|
67
|
+
* The origin comes from the backend's published configuration, never from a
|
|
68
|
+
* hardcoded tenant and never from the link the user typed — a link decides WHICH
|
|
69
|
+
* ITEM, it does not decide which host slidemind is allowed to send a bearer to.
|
|
70
|
+
*/
|
|
71
|
+
async function remoteSession({ needWrite = false, stop = fail } = {}) {
|
|
72
|
+
const { sessionsInScope, backendSupportsSharePointPublish } = await import(
|
|
73
|
+
"../bridge/client/transport.mjs"
|
|
74
|
+
);
|
|
75
|
+
const { getSharePointToken, ensureSignedIn, sharePointResource } = await import(
|
|
76
|
+
"../bridge/graph-auth.mjs"
|
|
77
|
+
);
|
|
78
|
+
const provider = await import("../bridge/sharepoint.mjs");
|
|
79
|
+
|
|
80
|
+
let sessions;
|
|
81
|
+
try {
|
|
82
|
+
sessions = sessionsInScope();
|
|
83
|
+
} catch (err) {
|
|
84
|
+
stop(err.message, 1, "not_connected");
|
|
85
|
+
}
|
|
86
|
+
if (sessions.length > 1) {
|
|
87
|
+
stop(
|
|
88
|
+
`several environments are connected (${sessions.map((s) => s.label).join(", ")}). ` +
|
|
89
|
+
`Name one with --backend <env>.`,
|
|
90
|
+
1,
|
|
91
|
+
"backend_ambiguous",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const backendURL = sessions[0].backendURL;
|
|
95
|
+
const origin = await sharePointResource(backendURL);
|
|
96
|
+
if (!origin) {
|
|
97
|
+
stop(
|
|
98
|
+
"this slidemind deployment publishes no SharePoint origin, so there is nothing to open or " +
|
|
99
|
+
"publish against. Adopt the file from disk instead: `slidemind adopt <file.pptx>`.",
|
|
100
|
+
1,
|
|
101
|
+
"no_sharepoint_origin",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (needWrite && !(await backendSupportsSharePointPublish(backendURL))) {
|
|
105
|
+
stop(
|
|
106
|
+
"publishing back to SharePoint is switched off for this deployment (`remote_saving_disabled`). " +
|
|
107
|
+
"Prepare a manual handoff instead: `slidemind publish <deck> --manual`.",
|
|
108
|
+
1,
|
|
109
|
+
"remote_saving_disabled",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
await ensureSignedIn(backendURL);
|
|
113
|
+
const token = await getSharePointToken(backendURL);
|
|
114
|
+
if (!token) {
|
|
115
|
+
stop(
|
|
116
|
+
"no SharePoint access token — run `slidemind login`. One sign-in mints both resources; if it " +
|
|
117
|
+
"succeeds and this still appears, the app registration is missing the SharePoint grant.",
|
|
118
|
+
1,
|
|
119
|
+
"login_required",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const client = provider.createClient({ origin, token });
|
|
123
|
+
return { provider, client, backendURL, caps: provider.capabilities(client.host) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function requireBound(deckDir, stop = fail) {
|
|
127
|
+
const remote = readRemote(deckDir);
|
|
128
|
+
if (!remote) {
|
|
129
|
+
stop(
|
|
130
|
+
`${deckSlug(deckDir)} is not bound to a SharePoint item. ` +
|
|
131
|
+
`\`slidemind open <link> --deck ${deckSlug(deckDir)}\` binds one.`,
|
|
132
|
+
1,
|
|
133
|
+
"identity_unresolved",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return remote;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A deck command holds ownership for its whole run, and re-enters an open edit
|
|
140
|
+
// session rather than queueing behind it (SLIDEMIND_OWNER).
|
|
141
|
+
function withOwner(deckDir, command, fn, stop = fail) {
|
|
142
|
+
let held;
|
|
143
|
+
try {
|
|
144
|
+
held = acquireOwner(deckDir, { command });
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (err?.code === "deck_busy") stop(err.message, 2, "deck_busy");
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
return Promise.resolve()
|
|
150
|
+
.then(() => fn(held.token))
|
|
151
|
+
.finally(() => held.release());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The typed refusals the protocol raises. Each is a state the user can act on,
|
|
155
|
+
// so each gets a sentence and an exit code — never a stack trace, which tells
|
|
156
|
+
// them nothing and looks like slidemind broke rather than like their file moved.
|
|
157
|
+
const TYPED = {
|
|
158
|
+
remote_missing: (slug) =>
|
|
159
|
+
`${slug}'s file is gone from SharePoint. Nothing local was touched and no write will be attempted ` +
|
|
160
|
+
`until you point the deck somewhere else: \`slidemind open <link> --deck ${slug} --rebind\`.`,
|
|
161
|
+
identity_unresolved: (slug) =>
|
|
162
|
+
`${slug} is not bound to a SharePoint item. \`slidemind open <link> --deck ${slug}\` binds one.`,
|
|
163
|
+
snapshot_raced: () =>
|
|
164
|
+
`the file kept changing while it was being read, so nothing was staged. Try again in a moment.`,
|
|
165
|
+
offline: () => `SharePoint could not be reached. Your work and the last known state are untouched.`,
|
|
166
|
+
conditional_write_unsupported: (slug, message) => message,
|
|
167
|
+
origin_not_allowed: (slug, message) => message,
|
|
168
|
+
rebind_required: (slug, message) => message,
|
|
169
|
+
ownership_lost: (slug, message) => message,
|
|
170
|
+
generation_moved: (slug, message) => message,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
async function guarded(deckDir, fn, stop = fail) {
|
|
174
|
+
try {
|
|
175
|
+
return await fn();
|
|
176
|
+
} catch (err) {
|
|
177
|
+
const render = TYPED[err?.code];
|
|
178
|
+
if (render) stop(render(deckSlug(deckDir), err.message), 2, err.code);
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// open
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
// --json is read by an agent, so it carries what the next command needs and
|
|
188
|
+
// nothing it can read from disk: the full receipt and operation journal live
|
|
189
|
+
// under state/receipts and state/operations.
|
|
190
|
+
const pick = (o, keys) => (o ? Object.fromEntries(keys.filter((k) => o[k] !== undefined).map((k) => [k, o[k]])) : o);
|
|
191
|
+
const briefItem = (item) => pick(item, ["itemId", "driveId", "name", "webUrl", "eTag", "size", "lastModifiedDateTime"]);
|
|
192
|
+
const briefRemote = (r) => pick(r, ["documentInstance", "itemId", "driveId", "name", "webUrl", "acceptedETag", "lastPulledAt", "access", "capabilities"]);
|
|
193
|
+
function briefPublish(result) {
|
|
194
|
+
return {
|
|
195
|
+
...pick(result, ["status", "reason", "why", "message", "blockers", "staged", "pendingRefs", "callbackFailed", "advanced"]),
|
|
196
|
+
// An unknown outcome is only actionable with the recovery verdicts that produced it.
|
|
197
|
+
...(result.recovered?.length ? { recovered: result.recovered.map((r) => pick(r, ["operationId", "action", "why"])) } : {}),
|
|
198
|
+
...(result.receipt ? { route: result.receipt.route, item: briefItem(result.receipt.item), version: result.receipt.version, candidateId: result.receipt.candidateId } : {}),
|
|
199
|
+
...(result.operation ? { operationId: result.operation.operationId } : {}),
|
|
200
|
+
...(result.handoff ? { handoff: result.handoff.dir } : {}),
|
|
201
|
+
...(result.destination === 'saved' ? { destination: true } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function cmdOpen(rest) {
|
|
206
|
+
let target = null;
|
|
207
|
+
let deck = null;
|
|
208
|
+
let rebind = false;
|
|
209
|
+
let detach = false;
|
|
210
|
+
let keep = false;
|
|
211
|
+
let slides = null;
|
|
212
|
+
let asJson = false;
|
|
213
|
+
let ordinary = false;
|
|
214
|
+
const companionInputs = [];
|
|
215
|
+
for (let i = 0; i < rest.length; i++) {
|
|
216
|
+
const a = rest[i];
|
|
217
|
+
if (a === "--deck") deck = rest[++i] ?? null;
|
|
218
|
+
else if (a === "--rebind") rebind = true;
|
|
219
|
+
else if (a === "--detach") detach = true;
|
|
220
|
+
else if (a === "--keep") keep = true;
|
|
221
|
+
else if (a === "--slides") slides = rest[++i] ?? null;
|
|
222
|
+
else if (a === "--ordinary") ordinary = true;
|
|
223
|
+
else if (a === "--companion") companionInputs.push(rest[++i]);
|
|
224
|
+
else if (a === "--json") asJson = true;
|
|
225
|
+
else if (a.startsWith("-")) fail(`unknown flag: ${a}`);
|
|
226
|
+
else if (!target) target = a;
|
|
227
|
+
else fail(`unexpected argument: ${a}`);
|
|
228
|
+
}
|
|
229
|
+
if (!target) {
|
|
230
|
+
fail(
|
|
231
|
+
`usage: slidemind open <link|file.pptx> --deck <name> [--slides 2,7|all] [--rebind|--keep|--detach]`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const local = /^[a-zA-Z]:[\\/]|^[./~]|^\\\\/.test(target) || fs.existsSync(target);
|
|
236
|
+
const slug = deck ?? path.basename(String(target).split(/[?#]/)[0], ".pptx") ?? "deck";
|
|
237
|
+
const deckRootPath = resolveDeckRoot(slug);
|
|
238
|
+
fs.mkdirSync(path.join(deckRootPath, ".slidemind"), { recursive: true });
|
|
239
|
+
let deckDir = resolveDeckArg(slug, { allowUnfinished: true });
|
|
240
|
+
const activeDir = deckDir;
|
|
241
|
+
const held = acquireOwner(activeDir, { command: 'open' });
|
|
242
|
+
const existing = readRemote(activeDir);
|
|
243
|
+
const previousBase = readRevision(activeDir)?.base?.sha256;
|
|
244
|
+
const tx = stageWorkspace(activeDir);
|
|
245
|
+
deckDir = tx.workspace;
|
|
246
|
+
try {
|
|
247
|
+
|
|
248
|
+
if (local) {
|
|
249
|
+
// A local file says nothing about where a deck should publish. An existing
|
|
250
|
+
// binding is therefore a question for the user, never an assumption: keeping
|
|
251
|
+
// it, detaching it, and pointing it somewhere else are three different
|
|
252
|
+
// intentions and only they know which one this is.
|
|
253
|
+
if (existing && !keep && !detach) {
|
|
254
|
+
fail(
|
|
255
|
+
`${slug} publishes to ${existing.name ?? existing.documentInstance}, and a local file does not ` +
|
|
256
|
+
`say whether that should stay true. Pass --keep to adopt these bytes and keep publishing there, ` +
|
|
257
|
+
`or --detach to make this a local-only deck.`,
|
|
258
|
+
2,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
if (existing && detach) detachRemote(deckDir);
|
|
262
|
+
const result = await adoptLocal(deckDir, target, { slides });
|
|
263
|
+
const context = contextOfBytes(fs.readFileSync(target));
|
|
264
|
+
if (ordinary) classifyOrdinary(deckDir, context);
|
|
265
|
+
if (context?.context) {
|
|
266
|
+
const companions = Object.fromEntries(companionInputs.map(value => {
|
|
267
|
+
const split = value?.indexOf('=') ?? -1;
|
|
268
|
+
if (split < 1) fail('--companion must be artifactId=/path/to/file');
|
|
269
|
+
return [value.slice(0, split), fs.readFileSync(path.resolve(value.slice(split + 1)))];
|
|
270
|
+
}));
|
|
271
|
+
const restored = await hydrateContext(deckDir, { context, companions, note, provider: {}, client: null });
|
|
272
|
+
if (restored.blockers.length) fail(restored.blockers.map(b => b.message).join('\n'), 2);
|
|
273
|
+
}
|
|
274
|
+
tx.activate();
|
|
275
|
+
if (asJson) return json({ deck: slug, mode: result.mode, remote: briefRemote(readRemote(deckDir)) });
|
|
276
|
+
process.stdout.write(
|
|
277
|
+
`${slug}: ${result.mode} from ${path.basename(target)}` +
|
|
278
|
+
(existing && keep ? ` (still publishing to ${existing.name})` : "") +
|
|
279
|
+
(existing && detach ? ` (detached — local only now)` : "") +
|
|
280
|
+
`\n`,
|
|
281
|
+
);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const { provider, client, caps } = await remoteSession();
|
|
286
|
+
const item = await guarded(deckDir, () => provider.resolveLink(client, target));
|
|
287
|
+
const tenant = new URL(client.host).hostname;
|
|
288
|
+
const instance = documentInstance({
|
|
289
|
+
provider: "sharepoint",
|
|
290
|
+
tenant,
|
|
291
|
+
driveId: item.driveId,
|
|
292
|
+
itemId: item.itemId,
|
|
293
|
+
});
|
|
294
|
+
if (existing && existing.documentInstance !== instance && !rebind) {
|
|
295
|
+
// Dirty work is never in danger here: nothing has been written yet.
|
|
296
|
+
const status = revisionStatus(deckDir);
|
|
297
|
+
fail(
|
|
298
|
+
`${slug} is bound to ${existing.name ?? existing.documentInstance}; that link resolves to a ` +
|
|
299
|
+
`different item. Pass --rebind to point this deck at it` +
|
|
300
|
+
(status.localDirty
|
|
301
|
+
? ` (${status.dirtySlides.length} slide(s) carry unpublished edits — they are kept either way)`
|
|
302
|
+
: "") +
|
|
303
|
+
`, or open the link into a new deck with --deck <other-name>.`,
|
|
304
|
+
2,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const snapshot = await guarded(deckDir, () =>
|
|
309
|
+
provider.downloadSnapshot(client, { driveId: item.driveId, itemId: item.itemId }),
|
|
310
|
+
);
|
|
311
|
+
const context = contextOfBytes(snapshot.bytes);
|
|
312
|
+
const identity = classifyInstance({
|
|
313
|
+
context,
|
|
314
|
+
resolved: { ...item, tenant },
|
|
315
|
+
remote: existing,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const tmp = path.join(
|
|
319
|
+
fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-open-")),
|
|
320
|
+
item.name || `${slug}.pptx`,
|
|
321
|
+
);
|
|
322
|
+
fs.writeFileSync(tmp, snapshot.bytes);
|
|
323
|
+
let adopted;
|
|
324
|
+
try {
|
|
325
|
+
adopted = await adoptLocal(deckDir, tmp, { slides });
|
|
326
|
+
} finally {
|
|
327
|
+
fs.rmSync(path.dirname(tmp), { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
bindRemote(deckDir, {
|
|
331
|
+
identity: { provider: "sharepoint", tenant, driveId: item.driveId, itemId: item.itemId },
|
|
332
|
+
labels: { name: item.name, webUrl: item.webUrl, parentId: item.parentId },
|
|
333
|
+
rebind,
|
|
334
|
+
acceptedETag: snapshot.revision?.eTag ?? item.eTag,
|
|
335
|
+
acceptedCTag: snapshot.revision?.cTag ?? item.cTag,
|
|
336
|
+
acceptedSha256: snapshot.sha256,
|
|
337
|
+
observedRevision: {
|
|
338
|
+
eTag: item.eTag,
|
|
339
|
+
cTag: item.cTag,
|
|
340
|
+
size: item.size,
|
|
341
|
+
lastModifiedDateTime: item.lastModifiedDateTime,
|
|
342
|
+
},
|
|
343
|
+
lastPulledAt: new Date().toISOString(),
|
|
344
|
+
// The read just succeeded, so read access is EVIDENCE. Write access stays
|
|
345
|
+
// unknown on purpose: the only honest way to learn it is the publish the
|
|
346
|
+
// user actually asks for, never a surprise probe against their library.
|
|
347
|
+
access: { read: "allowed", write: "unknown" },
|
|
348
|
+
// Cached so `ready` can report the mode from disk instead of turning every
|
|
349
|
+
// session start into a SharePoint round trip.
|
|
350
|
+
capabilities: caps.routes,
|
|
351
|
+
// A copy does not inherit authority over the original's shared inputs. This
|
|
352
|
+
// is the default and it is never flipped by metadata — only by the user.
|
|
353
|
+
sharedWorkbookAuthority: false,
|
|
354
|
+
...(identity.kind === "fork"
|
|
355
|
+
? { forkedFrom: context?.context?.documentInstance ?? null, deckId: instance }
|
|
356
|
+
: { deckId: context?.context?.deckId ?? instance }),
|
|
357
|
+
extensions: context?.context?.extensions ?? existing?.extensions ?? [],
|
|
358
|
+
committedRefs: context?.context?.extensions?.flatMap((e) => e.refs ?? []) ?? existing?.committedRefs ?? [],
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const lines = [
|
|
362
|
+
`${slug}: ${adopted.mode} from ${item.name} (${snapshot.bytes.length} bytes)`,
|
|
363
|
+
];
|
|
364
|
+
if (identity.kind === "fork") {
|
|
365
|
+
lines.push(
|
|
366
|
+
` this is a COPY — it was published from a different item, so its logical identity was reminted ` +
|
|
367
|
+
`and its mutable context will fork on the first change rather than touch the original's.`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
if (identity.kind === "unknown") {
|
|
371
|
+
lines.push(
|
|
372
|
+
` this file carries no slidemind context and nothing local describes it, so its origin is UNKNOWN. ` +
|
|
373
|
+
`Inspect and edit freely; a managed publish needs either recovered context or an explicit ` +
|
|
374
|
+
`\`--ordinary\` classification.`,
|
|
375
|
+
);
|
|
376
|
+
writeRemote(deckDir, { ...readRemote(deckDir), originUnknown: true });
|
|
377
|
+
}
|
|
378
|
+
if (context?.corrupt) {
|
|
379
|
+
lines.push(` its context tag is present but unreadable (${context.corrupt}) — gates are NOT dropped.`);
|
|
380
|
+
}
|
|
381
|
+
if (ordinary) classifyOrdinary(deckDir, context);
|
|
382
|
+
if (context?.context) {
|
|
383
|
+
const companions = {};
|
|
384
|
+
for (const value of companionInputs) {
|
|
385
|
+
const split = value?.indexOf('=') ?? -1;
|
|
386
|
+
if (split < 1) fail('--companion must be artifactId=<file-or-link>');
|
|
387
|
+
const id = value.slice(0, split), location = value.slice(split + 1);
|
|
388
|
+
if (fs.existsSync(location)) companions[id] = fs.readFileSync(location);
|
|
389
|
+
else { const item = await provider.resolveLink(client, location); companions[id] = (await provider.downloadSnapshot(client, { driveId: item.driveId, itemId: item.itemId })).bytes; }
|
|
390
|
+
}
|
|
391
|
+
const { declarations } = declaredExtensions(deckDir, context);
|
|
392
|
+
const hydrated = await hydrateContext(deckDir, { provider, client, context, note, companions });
|
|
393
|
+
if (hydrated.blockers.length) fail(hydrated.blockers.map(b => b.message).join("\n"), 2);
|
|
394
|
+
if (declarations.length) {
|
|
395
|
+
lines.push(
|
|
396
|
+
` extensions: ${declarations.map((d) => d.id).join(", ")}` +
|
|
397
|
+
(hydrated.written.length ? ` — restored ${hydrated.written.length} artifact(s)` : ""),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
if (hydrated.slides.length) {
|
|
401
|
+
lines.push(` it asks for slides ${hydrated.slides.join(", ")} to be editable: \`slidemind open <link> --deck ${slug} --slides ${hydrated.slides.join(",")}\``);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (previousBase && previousBase !== snapshot.sha256) {
|
|
405
|
+
if ((existing?.extensions ?? []).some(e => e.required !== false && !(context?.context?.extensions ?? []).some(c => c.id === e.id))) fail('The incoming copy lost required context; the active deck is unchanged.', 2);
|
|
406
|
+
const { declarations } = declaredExtensions(deckDir, context);
|
|
407
|
+
const assessed = runDeclared(deckDir, declarations, 'assess', { revision: revisionStatus(deckDir), dependencies: context?.context?.extensions?.flatMap(e => e.refs ?? []) ?? [], context: context?.context ?? null });
|
|
408
|
+
if (assessed.blockers.length) fail(assessed.blockers.map(b => b.message).join('\n'), 2);
|
|
409
|
+
}
|
|
410
|
+
tx.activate();
|
|
411
|
+
if (asJson) {
|
|
412
|
+
return json({ deck: slug, mode: adopted.mode, identity, remote: briefRemote(readRemote(deckDir)) });
|
|
413
|
+
}
|
|
414
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
415
|
+
} finally { tx.dispose(); held.release(); }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Adoption itself is the existing route, unchanged: `open` adds identity and
|
|
419
|
+
// context to it, it does not reimplement what `adopt` already does safely.
|
|
420
|
+
async function adoptLocal(deckDir, file, { slides }) {
|
|
421
|
+
const { adoptWorkspace } = await import("../lib/adopt.mjs");
|
|
422
|
+
const selection =
|
|
423
|
+
slides === "all"
|
|
424
|
+
? null
|
|
425
|
+
: slides
|
|
426
|
+
? slides.split(",").flatMap((part) => {
|
|
427
|
+
const range = part.match(/^(\d+)-(\d+)$/);
|
|
428
|
+
if (!range) return [Number(part)];
|
|
429
|
+
const out = [];
|
|
430
|
+
for (let n = Number(range[1]); n <= Number(range[2]); n++) out.push(n);
|
|
431
|
+
return out;
|
|
432
|
+
})
|
|
433
|
+
: null;
|
|
434
|
+
try {
|
|
435
|
+
return await withOwner(deckDir, "open", (owner) =>
|
|
436
|
+
adoptWorkspace({
|
|
437
|
+
pptxPath: file,
|
|
438
|
+
deckDir,
|
|
439
|
+
slides: slides === "all" ? "all" : selection,
|
|
440
|
+
note,
|
|
441
|
+
owner,
|
|
442
|
+
}),
|
|
443
|
+
);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
if (err?.code === "needs_reconcile") fail(err.message, 2);
|
|
446
|
+
throw err;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// pull
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
export async function cmdPull(rest) {
|
|
455
|
+
const { deck, passthrough } = splitDeckArg(rest, "pull");
|
|
456
|
+
const asJson = passthrough.includes("--json");
|
|
457
|
+
const checkOnly = passthrough.includes("--check");
|
|
458
|
+
for (const a of passthrough) {
|
|
459
|
+
if (!["--json", "--check"].includes(a)) fail(`unknown flag: ${a}`);
|
|
460
|
+
}
|
|
461
|
+
const deckDir = resolveDeckArg(deck, { allowUnfinished: true });
|
|
462
|
+
requireBound(deckDir);
|
|
463
|
+
const { provider, client } = await remoteSession();
|
|
464
|
+
|
|
465
|
+
if (checkOnly) {
|
|
466
|
+
// Metadata only, by construction: ten of these download nothing.
|
|
467
|
+
const freshness = await guarded(deckDir, () => checkFreshness(deckDir, { provider, client }));
|
|
468
|
+
if (asJson) return json({ ...freshness, item: briefItem(freshness.item) });
|
|
469
|
+
process.stdout.write(
|
|
470
|
+
`${deckSlug(deckDir)}: ${freshness.changed ? "the remote copy has changed" : "current"}` +
|
|
471
|
+
(freshness.renameOnly ? ` (renamed to ${freshness.item.name})` : "") +
|
|
472
|
+
(freshness.dependencyChanges.length
|
|
473
|
+
? `\n ${freshness.dependencyChanges.length} linked input(s) changed: ` +
|
|
474
|
+
freshness.dependencyChanges.map((d) => d.ref.artifactId).join(", ")
|
|
475
|
+
: "") +
|
|
476
|
+
`\n observed ${new Date().toISOString()}\n`,
|
|
477
|
+
);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const result = await guarded(deckDir, () =>
|
|
482
|
+
withOwner(deckDir, "pull", async (owner) => {
|
|
483
|
+
await recoverOperations(deckDir, { provider, client, owner, note });
|
|
484
|
+
return pullDeck(deckDir, { provider, client, owner, note });
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
if (asJson) return json({ ...result, item: briefItem(result.item) });
|
|
488
|
+
process.stdout.write(renderPull(deckDir, result));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Why a staged save was NOT taken in, in the words the gate used. A conflict
|
|
492
|
+
// is not one of these: it is listed slide by slide where it is rendered.
|
|
493
|
+
function notTakenIn(pull) {
|
|
494
|
+
const reasons = [
|
|
495
|
+
...(pull.blockers ?? []).map((b) => b.message),
|
|
496
|
+
...(pull.assessment?.blockers ?? []).map((b) => b.message),
|
|
497
|
+
...(pull.stagedContext ?? [])
|
|
498
|
+
.filter((a) => a.error)
|
|
499
|
+
.map((a) => `the exact '${a.artifactId}' their save pins could not be read (${a.error})`),
|
|
500
|
+
];
|
|
501
|
+
if (!reasons.length && pull.reason === "artifact_missing") {
|
|
502
|
+
reasons.push("their save does not carry the business context this deck requires");
|
|
503
|
+
}
|
|
504
|
+
return (
|
|
505
|
+
reasons.map((r) => ` - ${r}\n`).join("") +
|
|
506
|
+
` This is not a conflict to merge: pulling or publishing again stops here too until it is fixed.\n`
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function renderPull(deckDir, result) {
|
|
511
|
+
const slug = deckSlug(deckDir);
|
|
512
|
+
if (result.status === "current") {
|
|
513
|
+
return (
|
|
514
|
+
`${slug}: current — nothing was downloaded.\n` +
|
|
515
|
+
(result.dependencyChanges.length
|
|
516
|
+
? ` but ${result.dependencyChanges.length} linked input(s) changed: ` +
|
|
517
|
+
`${result.dependencyChanges.map((d) => d.ref.artifactId).join(", ")}\n` +
|
|
518
|
+
` that is an incoming business change; the deck's approved pairing is unchanged.\n`
|
|
519
|
+
: "")
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
if (result.status === "renamed") {
|
|
523
|
+
return `${slug}: renamed to ${result.item.name} — same item, same content, nothing re-hydrated.\n`;
|
|
524
|
+
}
|
|
525
|
+
if (result.status === "staged") {
|
|
526
|
+
if (result.conflicts?.length) {
|
|
527
|
+
return (
|
|
528
|
+
`${slug}: a newer save is on SharePoint and ${result.conflicts.length} slide(s) changed on BOTH sides, ` +
|
|
529
|
+
`so it was STAGED as ${result.staged.revisionId}. Nothing local was touched.\n` +
|
|
530
|
+
result.conflicts.map((c) => ` [${c.id}] ${c.kind}${c.file ? ` ${c.file}` : ""} — ${c.detail}\n`).join("") +
|
|
531
|
+
` Decide them: slidemind rebase ${slug} --incoming ${result.staged.revisionId}\n`
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return (
|
|
535
|
+
`${slug}: a newer save is on SharePoint; it was STAGED as ${result.staged.revisionId} but not taken in ` +
|
|
536
|
+
`(${result.reason}). Nothing local was touched.\n` +
|
|
537
|
+
notTakenIn(result)
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
const assessment = result.assessment?.blockers ?? [];
|
|
541
|
+
return (
|
|
542
|
+
`${slug}: merged their save ${result.staged.revisionId} (generation ${result.rebase.generation}).\n` +
|
|
543
|
+
` from their save: ${result.rebase.refreshed.length} slide(s) changed or added, ` +
|
|
544
|
+
`${result.rebase.removed.length} removed; your unpublished slide(s) kept: ${result.rebase.stillDirty.length}\n` +
|
|
545
|
+
(result.rebase.notes ?? []).map((n) => ` note: ${n}\n`).join("") +
|
|
546
|
+
(assessment.length
|
|
547
|
+
? ` business assessment blocks publication: ${assessment.map((b) => b.message).join("; ")}\n`
|
|
548
|
+
: "")
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ---------------------------------------------------------------------------
|
|
553
|
+
// publish
|
|
554
|
+
// ---------------------------------------------------------------------------
|
|
555
|
+
|
|
556
|
+
export async function cmdPublish(rest) {
|
|
557
|
+
const { deck, passthrough } = splitDeckArg(rest, "publish");
|
|
558
|
+
let candidateId = null;
|
|
559
|
+
let asJson = false;
|
|
560
|
+
let manual = false;
|
|
561
|
+
let sharedWorkbook = false;
|
|
562
|
+
let folder = null, name = null;
|
|
563
|
+
for (let i = 0; i < passthrough.length; i++) {
|
|
564
|
+
const a = passthrough[i];
|
|
565
|
+
if (a === "--candidate") candidateId = passthrough[++i] ?? null;
|
|
566
|
+
else if (a === "--json") asJson = true;
|
|
567
|
+
else if (a === "--manual") manual = true;
|
|
568
|
+
else if (a === "--folder") folder = passthrough[++i] ?? null;
|
|
569
|
+
else if (a === "--name") name = passthrough[++i] ?? null;
|
|
570
|
+
else if (a === "--shared-workbook") sharedWorkbook = true;
|
|
571
|
+
else fail(`unknown flag: ${a}`);
|
|
572
|
+
}
|
|
573
|
+
const deckDir = resolveDeckArg(deck, { allowUnfinished: true });
|
|
574
|
+
if (Boolean(folder) !== Boolean(name)) fail('--folder and --name must be supplied together.');
|
|
575
|
+
if (name && (!/\.pptx$/i.test(name) || /[\\/]/.test(name))) fail('--name must be a plain .pptx filename.');
|
|
576
|
+
let result;
|
|
577
|
+
try {
|
|
578
|
+
result = await runPublish(deckDir, { candidateId, manual, folder, name, sharedWorkbook });
|
|
579
|
+
} catch (err) {
|
|
580
|
+
if (!(err instanceof PublishStop)) throw err;
|
|
581
|
+
// --json still answers with one document: the typed reason, message on stderr too
|
|
582
|
+
if (asJson) json(stoppedJson(err));
|
|
583
|
+
fail(err.message, err.exitCode);
|
|
584
|
+
}
|
|
585
|
+
if (asJson) return json(briefPublish(result));
|
|
586
|
+
process.stdout.write(renderPublish(deckDir, result));
|
|
587
|
+
if (UNSAVED.includes(result.status)) process.exit(2);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// The outcomes that leave the deck NOT saved to SharePoint — exit 2, built-or-not.
|
|
591
|
+
const UNSAVED = ["blocked", "needs_reconcile", "unknown"];
|
|
592
|
+
|
|
593
|
+
// A publish that stops before the protocol runs: no session, writes switched
|
|
594
|
+
// off, a busy deck, a typed refusal. `slidemind publish` prints it and exits;
|
|
595
|
+
// a build that publishes itself reports it next to the build that DID happen.
|
|
596
|
+
class PublishStop extends Error {
|
|
597
|
+
constructor(message, exitCode = 1, reason = "stopped") {
|
|
598
|
+
super(message);
|
|
599
|
+
this.exitCode = exitCode;
|
|
600
|
+
this.reason = reason;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const stopPublish = (message, exitCode = 1, reason = "stopped") => {
|
|
604
|
+
throw new PublishStop(message, exitCode, reason);
|
|
605
|
+
};
|
|
606
|
+
// What a stop is in a --json payload: no protocol result, a typed reason.
|
|
607
|
+
const stoppedJson = (err) => ({ status: "not_published", reason: err.reason, message: err.message });
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Everything `slidemind publish` does once its arguments are read: the target
|
|
611
|
+
* (the binding, explicit --folder/--name, else the folder the workspace saved),
|
|
612
|
+
* the standing approval, the session, ownership and the protocol itself. Never
|
|
613
|
+
* exits — a refusal throws a PublishStop — so the command and the build that
|
|
614
|
+
* publishes itself run the one implementation and every one of its gates.
|
|
615
|
+
*/
|
|
616
|
+
async function runPublish(deckDir, { candidateId = null, manual = false, folder = null, name = null, sharedWorkbook = false } = {}) {
|
|
617
|
+
// No flags and no binding: the workspace's saved folder (destination.json)
|
|
618
|
+
// supplies the target, on the backend it was chosen against. The lib reads
|
|
619
|
+
// the same file, so the CLI only decides whether "not bound" is an error.
|
|
620
|
+
const saved = !folder && !readRemote(deckDir) ? savedDestination(deckDir) : null;
|
|
621
|
+
if (!folder && !saved) requireBound(deckDir, stopPublish);
|
|
622
|
+
if (saved?.backendURL && !process.env.SLIDEMIND_BACKEND?.trim()) process.env.SLIDEMIND_BACKEND = saved.backendURL;
|
|
623
|
+
if (!readRevision(deckDir)) {
|
|
624
|
+
stopPublish(
|
|
625
|
+
`${deckSlug(deckDir)} has no accepted base, so there is nothing to publish AGAINST. ` +
|
|
626
|
+
`Open it from its link first: slidemind open <link> --deck ${deckSlug(deckDir)}`,
|
|
627
|
+
1,
|
|
628
|
+
"no_base",
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
// The standing approval, checked here rather than inferred. It is one-time per
|
|
632
|
+
// deck and `build-pptx` already enforces it, so this cannot prompt a second
|
|
633
|
+
// time — it only refuses to publish a deck whose preview nobody ever approved.
|
|
634
|
+
const unapproved = (await import("../builders/review.mjs")).requireReviewApproved(deckDir);
|
|
635
|
+
if (unapproved) stopPublish(unapproved, 1, "review_required");
|
|
636
|
+
const { provider, client } = await remoteSession({ needWrite: !manual, stop: stopPublish });
|
|
637
|
+
const folderItem = folder ? await provider.resolveLink(client, folder) : null;
|
|
638
|
+
const destination = folderItem ? { driveId: folderItem.driveId, parentId: folderItem.itemId, name } : null;
|
|
639
|
+
|
|
640
|
+
return guarded(
|
|
641
|
+
deckDir,
|
|
642
|
+
() =>
|
|
643
|
+
withOwner(
|
|
644
|
+
deckDir,
|
|
645
|
+
"publish",
|
|
646
|
+
(owner) =>
|
|
647
|
+
publishDeck(deckDir, {
|
|
648
|
+
provider,
|
|
649
|
+
client,
|
|
650
|
+
owner,
|
|
651
|
+
candidateId,
|
|
652
|
+
manual,
|
|
653
|
+
destination,
|
|
654
|
+
sharedWorkbookAuthority: sharedWorkbook,
|
|
655
|
+
note,
|
|
656
|
+
onProgress: ({ uploaded, total }) =>
|
|
657
|
+
process.stderr.write(` uploading ${Math.round((uploaded / total) * 100)}%\r`),
|
|
658
|
+
}),
|
|
659
|
+
stopPublish,
|
|
660
|
+
),
|
|
661
|
+
stopPublish,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* The publish a build owes a deck connected to SharePoint: the file it was
|
|
667
|
+
* opened from (state/remote.json) or a folder the user connected in the
|
|
668
|
+
* workspace (state/destination.json). Read from the deck's own state at every
|
|
669
|
+
* build, so a folder connected while an agent is mid-deck is honoured by the
|
|
670
|
+
* very next build — nobody has to remember it. Null for a deck connected to
|
|
671
|
+
* nothing.
|
|
672
|
+
*
|
|
673
|
+
* Never exits and never throws: the build already happened, so a publish that
|
|
674
|
+
* did not save is REPORTED next to it — `text` for stderr, `json` for the
|
|
675
|
+
* build's payload — with exit code 2 (built, not on SharePoint). A build that
|
|
676
|
+
* withholds its own bytes (`withheld`) is reported without an attempt.
|
|
677
|
+
*/
|
|
678
|
+
export async function publishAfterBuild(deckDir, { candidateId = null, withheld = null } = {}) {
|
|
679
|
+
let connected;
|
|
680
|
+
try {
|
|
681
|
+
connected = Boolean(readRemote(deckDir) || savedDestination(deckDir));
|
|
682
|
+
} catch {
|
|
683
|
+
// an unfinished activation on a bound deck: publish's own recovery speaks to it
|
|
684
|
+
connected = true;
|
|
685
|
+
}
|
|
686
|
+
if (!connected) return null;
|
|
687
|
+
// The build itself said it is not ready (`withheld: { reason, message }`):
|
|
688
|
+
// named, not attempted — and not a second failure on top of the build's own.
|
|
689
|
+
if (withheld) {
|
|
690
|
+
return {
|
|
691
|
+
json: { status: "not_published", reason: withheld.reason, message: withheld.message },
|
|
692
|
+
text: `${deckSlug(deckDir)}: built, but not published to SharePoint — ${withheld.message}\n`,
|
|
693
|
+
exitCode: 0,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
try {
|
|
697
|
+
const result = await runPublish(deckDir, { candidateId });
|
|
698
|
+
return { json: briefPublish(result), text: renderPublish(deckDir, result), exitCode: UNSAVED.includes(result.status) ? 2 : 0 };
|
|
699
|
+
} catch (err) {
|
|
700
|
+
const message = err?.message ?? String(err);
|
|
701
|
+
return {
|
|
702
|
+
json: err instanceof PublishStop ? stoppedJson(err) : { status: "not_published", reason: err?.code ?? "error", message },
|
|
703
|
+
text: `${deckSlug(deckDir)}: built, but not published to SharePoint — ${message}\n`,
|
|
704
|
+
exitCode: 2,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export function renderPublish(deckDir, result) {
|
|
710
|
+
const slug = deckSlug(deckDir);
|
|
711
|
+
switch (result.status) {
|
|
712
|
+
case "committed":
|
|
713
|
+
return (
|
|
714
|
+
// The replace response carries no version number, only the new eTag;
|
|
715
|
+
// its trailing ",N" is the revision SharePoint's history counts by.
|
|
716
|
+
`${slug}: SAVED to SharePoint as ${
|
|
717
|
+
result.receipt.version?.versionId
|
|
718
|
+
? `version ${result.receipt.version.versionId}`
|
|
719
|
+
: `revision ${result.receipt.version?.eTag?.match(/,(\d+)"?$/)?.[1] ?? "(unnumbered)"}`
|
|
720
|
+
}.\n` +
|
|
721
|
+
` ${result.receipt.item?.name ?? ""} · ${result.receipt.route}\n` +
|
|
722
|
+
(result.callbackFailed
|
|
723
|
+
? ` local finalization is still pending — re-run publish to retry the callback (not the upload).\n`
|
|
724
|
+
: "")
|
|
725
|
+
);
|
|
726
|
+
case "needs_reconcile":
|
|
727
|
+
if (result.reason === "merged") {
|
|
728
|
+
const rebase = result.pull.rebase;
|
|
729
|
+
return (
|
|
730
|
+
`${slug}: somebody else saved first, so nothing was overwritten — their save is merged in ` +
|
|
731
|
+
`(${rebase.refreshed.length} slide(s) changed or added, ${rebase.removed.length} removed; ` +
|
|
732
|
+
`your ${rebase.stillDirty.length} unpublished slide(s) kept).\n` +
|
|
733
|
+
(rebase.notes ?? []).map((n) => ` note: ${n}\n`).join("") +
|
|
734
|
+
` Rebuild: slidemind build-pptx ${slug} — it publishes again.\n`
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
if (result.dependencies) {
|
|
738
|
+
return (
|
|
739
|
+
`${slug}: a linked input changed since this deck was paired with it, so nothing was published.\n` +
|
|
740
|
+
result.dependencies
|
|
741
|
+
.filter((d) => d.changed || d.unreadable)
|
|
742
|
+
.map((d) => ` - ${d.ref?.artifactId ?? "input"}: ${d.unreadable ? `unreadable (${d.error})` : "changed"}\n`)
|
|
743
|
+
.join("") +
|
|
744
|
+
` Run \`slidemind pull ${slug}\` and try again.\n`
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
return (
|
|
748
|
+
`${slug}: somebody else saved first, so nothing was overwritten.\n` +
|
|
749
|
+
(result.pull?.conflicts?.length
|
|
750
|
+
? result.pull.conflicts.map((c) => ` [${c.id}] ${c.kind}${c.file ? ` ${c.file}` : ""} — ${c.detail}\n`).join("") +
|
|
751
|
+
` Decide them: slidemind rebase ${slug} --incoming ${result.staged.revisionId}\n`
|
|
752
|
+
: result.pull?.status === "staged"
|
|
753
|
+
? ` Their save is STAGED as ${result.staged.revisionId} but could not be taken in (${result.pull.reason}).\n` +
|
|
754
|
+
notTakenIn(result.pull)
|
|
755
|
+
: ` Run \`slidemind pull ${slug}\` (it merges their save), then rebuild — \`slidemind build-pptx ${slug}\` publishes again.\n`) +
|
|
756
|
+
(result.pendingRefs?.length
|
|
757
|
+
? ` ${result.pendingRefs.length} uploaded artifact(s) stay PENDING — the stored deck still ` +
|
|
758
|
+
`points at the context it was approved with.\n`
|
|
759
|
+
: "")
|
|
760
|
+
);
|
|
761
|
+
case "manual":
|
|
762
|
+
return (
|
|
763
|
+
`${slug}: prepared for manual upload — nothing was written to SharePoint (${result.reason}).\n` +
|
|
764
|
+
` ${result.handoff.dir}\n` +
|
|
765
|
+
` status: pendingManualUpload. Upload the files it lists, then \`slidemind pull ${slug}\`.\n`
|
|
766
|
+
);
|
|
767
|
+
case "unknown":
|
|
768
|
+
return (
|
|
769
|
+
`${slug}: the outcome of this write is UNKNOWN (${result.why}).\n` +
|
|
770
|
+
` Nothing will be re-uploaded. Re-run \`slidemind publish ${slug}\` — it inspects first.\n`
|
|
771
|
+
);
|
|
772
|
+
default:
|
|
773
|
+
return (
|
|
774
|
+
`${slug}: not published (${result.reason}).\n` +
|
|
775
|
+
(result.message ? ` ${result.message}\n` : "") +
|
|
776
|
+
(result.blockers ?? []).map((b) => ` - ${b.message}\n`).join("")
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// ---------------------------------------------------------------------------
|
|
782
|
+
// rename
|
|
783
|
+
// ---------------------------------------------------------------------------
|
|
784
|
+
|
|
785
|
+
export async function cmdRename(rest) {
|
|
786
|
+
const { deck, passthrough } = splitDeckArg(rest, "rename");
|
|
787
|
+
let name = null;
|
|
788
|
+
let asJson = false;
|
|
789
|
+
for (let i = 0; i < passthrough.length; i++) {
|
|
790
|
+
const a = passthrough[i];
|
|
791
|
+
if (a === "--name") name = passthrough[++i] ?? null;
|
|
792
|
+
else if (a === "--json") asJson = true;
|
|
793
|
+
else fail(`unknown flag: ${a}`);
|
|
794
|
+
}
|
|
795
|
+
if (!name) fail(`usage: slidemind rename <deck> --name <file.pptx>`);
|
|
796
|
+
if (name.includes("/") || name.includes("\\")) {
|
|
797
|
+
fail("a rename changes the file's NAME; moving it to another folder needs an explicit rebind.");
|
|
798
|
+
}
|
|
799
|
+
const deckDir = resolveDeckArg(deck, { allowUnfinished: true });
|
|
800
|
+
requireBound(deckDir);
|
|
801
|
+
const { provider, client } = await remoteSession({ needWrite: true });
|
|
802
|
+
const result = await guarded(deckDir, () =>
|
|
803
|
+
withOwner(deckDir, "rename", () => renameBound(deckDir, { provider, client, name, note })),
|
|
804
|
+
);
|
|
805
|
+
if (asJson) return json(result);
|
|
806
|
+
if (result.status === "committed") {
|
|
807
|
+
process.stdout.write(`${deckSlug(deckDir)}: renamed to ${result.name} — same item, nothing rendered.\n`);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
process.stdout.write(
|
|
811
|
+
`${deckSlug(deckDir)}: not renamed (${result.reason})` +
|
|
812
|
+
(result.reason === "name_conflict" ? ` — '${name}' already exists there.` : "") +
|
|
813
|
+
`\n`,
|
|
814
|
+
);
|
|
815
|
+
process.exit(2);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export { remoteReadiness } from "../lib/remote.mjs";
|