@1agh/maude 0.52.2 → 0.53.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.
@@ -28,6 +28,7 @@ import { useEffect, useRef } from 'react';
28
28
  import { useArtboardsContext, useDragStateContext } from './canvas-lib.tsx';
29
29
  import { resolveSelectionEl, scopedCdSelector, selectorIndex } from './dom-selection.ts';
30
30
  import { isEditableTarget } from './input-router.tsx';
31
+ import { isReadOnlyCanvas } from './read-only-mode.ts';
31
32
  import { type Selection, useSelectionSet } from './use-selection-set.tsx';
32
33
  import { useToolModeOptional } from './use-tool-mode.tsx';
33
34
 
@@ -127,6 +128,7 @@ export function useKeyboardDiscipline(): void {
127
128
  // write, so post to the parent shell (pinned to the active canvas + undo).
128
129
  // preventDefault so the browser doesn't bookmark the page.
129
130
  if (isMeta && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'd') {
131
+ if (isReadOnlyCanvas()) return; // Cloud Phase 25 C2 — duplicate is a write
130
132
  const one = selSet.selected.length === 1 ? selSet.selected[0] : null;
131
133
  if (one?.id) {
132
134
  e.preventDefault();
@@ -146,6 +148,8 @@ export function useKeyboardDiscipline(): void {
146
148
  // selection's authored appearance (shell-side); paste applies it to the
147
149
  // currently selected element. The shell holds the clipboard + does the write.
148
150
  if (isMeta && e.altKey && (e.key.toLowerCase() === 'c' || e.key.toLowerCase() === 'v')) {
151
+ // Cloud Phase 25 C2 — copy-style is a read and stays; paste-style writes.
152
+ if (e.key.toLowerCase() === 'v' && isReadOnlyCanvas()) return;
149
153
  const one = selSet.selected.length === 1 ? selSet.selected[0] : null;
150
154
  if (one?.id) {
151
155
  e.preventDefault();
@@ -224,6 +228,7 @@ export function useKeyboardDiscipline(): void {
224
228
  // against text-editing focus above; skip modifier combos + non-single /
225
229
  // artboard-only selections (whole-artboard delete is out of scope here).
226
230
  if ((e.key === 'Delete' || e.key === 'Backspace') && !isMeta && !e.altKey) {
231
+ if (isReadOnlyCanvas()) return; // Cloud Phase 25 C2 — delete is a write
227
232
  const one = selSet.selected.length === 1 ? selSet.selected[0] : null;
228
233
  if (one?.id) {
229
234
  e.preventDefault();
@@ -257,6 +262,7 @@ export function useKeyboardDiscipline(): void {
257
262
  const delta = nudgeDelta({ key: e.key, shift: e.shiftKey });
258
263
  if (!delta) return;
259
264
  if (isMeta || e.altKey) return; // modifier combos reserved for future
265
+ if (isReadOnlyCanvas()) return; // Cloud Phase 25 C2 — nudge commits a write
260
266
 
261
267
  // Arrow nudge — a single OUT-OF-FLOW element (position:absolute/fixed).
262
268
  // In-flow elements are a no-op (nudging a flow element is ambiguous — it has
@@ -23,6 +23,7 @@ import {
23
23
 
24
24
  import { TOOL_CURSORS } from './canvas-cursors.ts';
25
25
  import type { Tool } from './input-router.tsx';
26
+ import { isReadOnlyCanvas } from './read-only-mode.ts';
26
27
 
27
28
  // ─────────────────────────────────────────────────────────────────────────────
28
29
  // Types
@@ -69,6 +70,23 @@ export const DEFAULT_TOOLS: readonly ToolDescriptor[] = Object.freeze([
69
70
  { id: 'eraser', label: 'Eraser', shortcut: 'E', cursor: TOOL_CURSORS.eraser },
70
71
  ]);
71
72
 
73
+ /**
74
+ * Cloud Phase 25 C2 — the tools a READ-ONLY session keeps: navigate and
75
+ * inspect, nothing that writes. Everything else (comment included — the cell
76
+ * refuses viewer comments until Phase 25 C3 lands them on its allowlist) is
77
+ * ABSENT from the palette, its letter shortcut dead, and `setTool` refuses it
78
+ * (which also covers the shell's `tool-set` postMessage lane).
79
+ */
80
+ const READ_ONLY_TOOL_IDS: ReadonlySet<Tool> = new Set<Tool>(['browse', 'move', 'hand']);
81
+
82
+ /** Pure filter — exported for unit tests. */
83
+ export function filterToolsForReadOnly(
84
+ tools: readonly ToolDescriptor[],
85
+ readOnly: boolean
86
+ ): readonly ToolDescriptor[] {
87
+ return readOnly ? tools.filter((t) => READ_ONLY_TOOL_IDS.has(t.id)) : tools;
88
+ }
89
+
72
90
  interface ToolContextValue {
73
91
  tool: Tool;
74
92
  setTool: (t: Tool) => void;
@@ -92,7 +110,7 @@ const ToolContext = createContext<ToolContextValue | null>(null);
92
110
 
93
111
  export function ToolProvider({
94
112
  children,
95
- tools = DEFAULT_TOOLS,
113
+ tools: toolsProp = DEFAULT_TOOLS,
96
114
  // feature-4 (browse/move split, DDR-187) — boot into `browse` so a freshly
97
115
  // opened mock is ALIVE (native pass-through). V flips to the Move (select)
98
116
  // tool. This is the user-decided boot posture (2026-07-15), zero regression
@@ -103,17 +121,29 @@ export function ToolProvider({
103
121
  tools?: readonly ToolDescriptor[];
104
122
  initial?: Tool;
105
123
  }) {
124
+ // Cloud Phase 25 C2 — a read-only canvas keeps only navigate/inspect tools.
125
+ // Filtering HERE covers every consumer at once: the palette renders from
126
+ // `tools`, the input router's letter shortcuts resolve against `tools`, and
127
+ // `setTool` below refuses anything outside the list (the shell `tool-set`
128
+ // postMessage lane included).
129
+ const tools = useMemo(() => filterToolsForReadOnly(toolsProp, isReadOnlyCanvas()), [toolsProp]);
106
130
  const [tool, setToolState] = useState<Tool>(initial);
107
131
  const [sticky, setSticky] = useState<{ tool: Tool | null; locked: boolean }>(() => ({
108
132
  tool: null,
109
133
  locked: false,
110
134
  }));
111
- const setTool = useCallback((t: Tool) => {
112
- setToolState(t);
113
- // Single-click on a different tool clears any sticky lock — sticky is
114
- // a per-tool flag, not global.
115
- setSticky((prev) => (prev.locked && prev.tool === t ? prev : { tool: null, locked: false }));
116
- }, []);
135
+ const setTool = useCallback(
136
+ (t: Tool) => {
137
+ // Refuse tools that aren't offered (read-only filtering above) — a
138
+ // shortcut or postMessage can't arm a tool the palette doesn't show.
139
+ if (!tools.some((d) => d.id === t)) return;
140
+ setToolState(t);
141
+ // Single-click on a different tool clears any sticky lock — sticky is
142
+ // a per-tool flag, not global.
143
+ setSticky((prev) => (prev.locked && prev.tool === t ? prev : { tool: null, locked: false }));
144
+ },
145
+ [tools]
146
+ );
117
147
  const toggleSticky = useCallback((t: Tool) => {
118
148
  setSticky((prev) => {
119
149
  if (prev.locked && prev.tool === t) return { tool: null, locked: false };
@@ -1,9 +1,36 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "open-your-project-in-a-browser",
6
+ "version": "0.53.0",
7
+ "date": "2026-08-02",
8
+ "kind": "feature",
9
+ "title": "Open your project in a browser — nothing to install",
10
+ "summary": "Send a teammate a link and they open the real project in their browser: the actual canvases, the actual design system. They can move things, edit text and colours, leave notes, and download the whole thing — signed in with their Maude account, with the same rights they have in the app.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "viewer-means-viewer",
15
+ "version": "0.53.0",
16
+ "date": "2026-08-02",
17
+ "kind": "feature",
18
+ "title": "A viewer sees no editing tools at all",
19
+ "summary": "Invite someone as a viewer and the editing simply is not there — not greyed out, not hidden, absent — in the browser and in the app alike. They can look, comment and download, which is what the role has always promised.",
20
+ "surface": "design-ui"
21
+ },
22
+ {
23
+ "id": "design-sync-pull-request",
24
+ "version": "0.53.0",
25
+ "date": "2026-08-02",
26
+ "kind": "feature",
27
+ "title": "Sync your designs into your own repo as a pull request",
28
+ "summary": "Point Maude at a GitHub repository and choose what lands: the whole workspace as a backup on its own branch, or your design folder inside your working repo — as a normal pull request you review and merge. Each choice says what it will do before you save it.",
29
+ "surface": "design-ui"
30
+ },
4
31
  {
5
32
  "id": "desktop-opens-again",
6
- "version": "0.52.0",
33
+ "version": "0.52.2",
7
34
  "date": "2026-08-02",
8
35
  "kind": "fix",
9
36
  "title": "The desktop app opens again",
package/cli/bin/maude.mjs CHANGED
@@ -21,7 +21,6 @@ const COMMANDS = {
21
21
  kg: () => import('../commands/kg.mjs'),
22
22
  help: () => import('../commands/help.mjs'),
23
23
  hub: () => import('../commands/hub.mjs'),
24
- share: () => import('../commands/share.mjs'),
25
24
  version: () => import('../commands/version.mjs'),
26
25
  };
27
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.52.2",
3
+ "version": "0.53.1",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -52,13 +52,13 @@
52
52
  "test:e2e:desktop:acp-cold-start": "pnpm --filter @maude/desktop-e2e e2e:acp-cold-start"
53
53
  },
54
54
  "optionalDependencies": {
55
- "@1agh/maude-darwin-arm64": "0.52.2",
56
- "@1agh/maude-darwin-x64": "0.52.2",
57
- "@1agh/maude-linux-arm64": "0.52.2",
58
- "@1agh/maude-linux-arm64-musl": "0.52.2",
59
- "@1agh/maude-linux-x64": "0.52.2",
60
- "@1agh/maude-linux-x64-musl": "0.52.2",
61
- "@1agh/maude-win32-x64": "0.52.2"
55
+ "@1agh/maude-darwin-arm64": "0.53.1",
56
+ "@1agh/maude-darwin-x64": "0.53.1",
57
+ "@1agh/maude-linux-arm64": "0.53.1",
58
+ "@1agh/maude-linux-arm64-musl": "0.53.1",
59
+ "@1agh/maude-linux-x64": "0.53.1",
60
+ "@1agh/maude-linux-x64-musl": "0.53.1",
61
+ "@1agh/maude-win32-x64": "0.53.1"
62
62
  },
63
63
  "files": [
64
64
  "cli",
@@ -1,188 +0,0 @@
1
- // `maude share` — publish a read-only view of a project. Cloud Phase 18.
2
- //
3
- // The effects half; every decision lives in cli/lib/share-plan.mjs.
4
- //
5
- // RENDERING HAPPENS HERE, ON THIS MACHINE. That is the containment invariant
6
- // (DDR-193 §2, narrowed by DDR-197), not an implementation detail: the vendor
7
- // serves finished pictures it never produced and never interprets. Moving this
8
- // to the server would be a one-line convenience that deletes the guarantee.
9
- //
10
- // maude share publish --project alligators --from <dir> [--name "Brno Alligators"]
11
- // maude share off --project alligators
12
- //
13
- // `--from` is a directory of screenshots — typically what `maude design smoke
14
- // --out-dir <dir>` just wrote, which is already one PNG per canvas.
15
-
16
- import { readdirSync, readFileSync, statSync } from 'node:fs';
17
- import { join, resolve } from 'node:path';
18
-
19
- import { parseArgs } from '../lib/argv.mjs';
20
- import { publishPlan, publishSummary, shareMarker, validProjectId } from '../lib/share-plan.mjs';
21
-
22
- export function usage() {
23
- return `maude share <publish|off> [options]
24
-
25
- Publish a read-only view of a design project, so someone can look at it in a
26
- browser without installing anything.
27
-
28
- publish upload the pictures in --from and turn sharing ON
29
- off turn sharing off (the pictures stay; nothing serves them)
30
-
31
- --project ID the project (lowercase, hyphens)
32
- --from DIR directory of screenshots to publish (publish only)
33
- --name "Title" what the view calls itself (default: the project id)
34
- --zone HOST default cloud.maude.sh
35
- --dry-run print the plan, upload nothing
36
- --json machine-readable result
37
-
38
- Storage credentials come from the environment:
39
- MAUDE_R2_ENDPOINT MAUDE_R2_ACCESS_KEY_ID MAUDE_R2_SECRET_ACCESS_KEY
40
- MAUDE_R2_BUCKET (default maude-cloud-assets)
41
-
42
- The pictures are taken on THIS machine and uploaded as finished images. The
43
- shared view never renders anything — that is what makes it safe to hand to
44
- someone who is not on your team.
45
- `;
46
- }
47
-
48
- /** Every file under `dir`, relative to it. */
49
- function listFiles(dir, prefix = '', out = []) {
50
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
51
- const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
52
- if (entry.isDirectory()) listFiles(join(dir, entry.name), rel, out);
53
- else if (entry.isFile()) out.push(rel);
54
- }
55
- return out;
56
- }
57
-
58
- function s3ConfigFromEnv(env = process.env) {
59
- const missing = [
60
- 'MAUDE_R2_ENDPOINT',
61
- 'MAUDE_R2_ACCESS_KEY_ID',
62
- 'MAUDE_R2_SECRET_ACCESS_KEY',
63
- ].filter((k) => !env[k]);
64
- if (missing.length) {
65
- throw new Error(
66
- `object storage is not configured — missing ${missing.join(', ')}.\n` +
67
- 'A publish needs somewhere to put the pictures.'
68
- );
69
- }
70
- return {
71
- endpoint: env.MAUDE_R2_ENDPOINT,
72
- bucket: env.MAUDE_R2_BUCKET ?? 'maude-cloud-assets',
73
- accessKeyId: env.MAUDE_R2_ACCESS_KEY_ID,
74
- secretAccessKey: env.MAUDE_R2_SECRET_ACCESS_KEY,
75
- region: 'auto',
76
- };
77
- }
78
-
79
- async function loadS3(pkgRoot) {
80
- for (const candidate of [
81
- resolve(pkgRoot, 'apps/hub/src/s3.mjs'),
82
- resolve(pkgRoot, '../apps/hub/src/s3.mjs'),
83
- ]) {
84
- const mod = await import(`file://${candidate}`).catch(() => null);
85
- if (mod) return mod;
86
- }
87
- throw new Error('the storage client (apps/hub/src/s3.mjs) was not found in this install');
88
- }
89
-
90
- export async function run({ args, pkgRoot }) {
91
- const { positional, flags } = parseArgs(args, { booleans: ['help', 'dry-run', 'json'] });
92
- const sub = positional[0];
93
- if (flags.help || !sub) {
94
- process.stdout.write(usage());
95
- return;
96
- }
97
-
98
- const project = validProjectId(flags.project);
99
- if (!project) {
100
- process.stderr.write(
101
- 'maude share: --project is required (lowercase letters, digits, hyphens)\n'
102
- );
103
- process.exit(2);
104
- }
105
- const zone = String(flags.zone ?? 'cloud.maude.sh');
106
-
107
- if (sub === 'off') {
108
- const s3 = await loadS3(pkgRoot);
109
- const marker = shareMarker(project, { enabled: false, name: flags.name });
110
- if (flags['dry-run']) {
111
- process.stdout.write(`Would turn sharing OFF for ${project}.\n`);
112
- return;
113
- }
114
- await s3.putObject(s3ConfigFromEnv(), marker.key, Buffer.from(marker.body));
115
- process.stdout.write(
116
- `Sharing is off for ${project}. The link no longer shows anything.\n` +
117
- 'The pictures are still in storage — nothing serves them.\n'
118
- );
119
- return;
120
- }
121
-
122
- if (sub !== 'publish') {
123
- process.stderr.write(`maude share: unknown command "${sub}"\n`);
124
- process.exit(2);
125
- }
126
-
127
- const from = flags.from ? resolve(String(flags.from)) : null;
128
- if (!from) {
129
- process.stderr.write('maude share publish: --from <dir> is required\n');
130
- process.exit(2);
131
- }
132
- try {
133
- if (!statSync(from).isDirectory()) throw new Error('not a directory');
134
- } catch {
135
- process.stderr.write(`maude share publish: ${from} is not a directory\n`);
136
- process.exit(2);
137
- }
138
-
139
- const { uploads, skipped } = publishPlan(listFiles(from), project);
140
- if (uploads.length === 0) {
141
- process.stderr.write(
142
- `maude share publish: nothing to publish in ${from}.\n` +
143
- 'The shared view serves PNG, JPEG, WebP and AVIF. Take screenshots first\n' +
144
- '(`maude design smoke --out-dir <dir>` writes one per canvas).\n'
145
- );
146
- process.exit(1);
147
- }
148
-
149
- if (flags['dry-run']) {
150
- process.stdout.write(`Would publish ${uploads.length} file(s) for ${project}:\n`);
151
- for (const u of uploads.slice(0, 20)) process.stdout.write(` ${u.from} → ${u.key}\n`);
152
- if (uploads.length > 20) process.stdout.write(` …and ${uploads.length - 20} more\n`);
153
- if (skipped.length) process.stdout.write(`Would skip ${skipped.length} unshareable file(s).\n`);
154
- return;
155
- }
156
-
157
- const s3 = await loadS3(pkgRoot);
158
- const cfg = s3ConfigFromEnv();
159
- let uploaded = 0;
160
- const failed = [];
161
- for (const u of uploads) {
162
- try {
163
- await s3.putObject(cfg, u.key, readFileSync(join(from, u.from)));
164
- uploaded += 1;
165
- } catch (err) {
166
- failed.push({ file: u.from, reason: err.message.slice(0, 120) });
167
- }
168
- }
169
-
170
- // The marker goes LAST. Its presence is what turns sharing on, so writing it
171
- // first would expose a half-published gallery — the same reasoning as the
172
- // backup manifest.
173
- const marker = shareMarker(project, { enabled: true, name: flags.name });
174
- await s3.putObject(cfg, marker.key, Buffer.from(marker.body));
175
-
176
- if (flags.json) {
177
- process.stdout.write(
178
- `${JSON.stringify({ ok: failed.length === 0, project, uploaded, skipped: skipped.length, failed, url: `https://view-${project}.${zone}` }, null, 2)}\n`
179
- );
180
- return;
181
- }
182
- process.stdout.write(`${publishSummary({ project, uploaded, skipped: skipped.length, zone })}\n`);
183
- if (failed.length) {
184
- process.stderr.write(`\n${failed.length} file(s) failed to upload:\n`);
185
- for (const f of failed.slice(0, 5)) process.stderr.write(` ${f.file} — ${f.reason}\n`);
186
- process.exit(1);
187
- }
188
- }
@@ -1,100 +0,0 @@
1
- // Publishing a shared view — the DECISION half. Cloud Phase 18 Task 1.
2
- //
3
- // WHO RENDERS. Snapshots are produced on a MEMBER's own machine and uploaded
4
- // as finished pictures. The vendor never renders a canvas — that is the whole
5
- // containment claim (DDR-193 §2 / DDR-197), and it is why publishing is a
6
- // local command rather than a server feature. A "publish" button that made the
7
- // server render would quietly delete the invariant.
8
- //
9
- // Pure: no filesystem, no network, no clock. What gets uploaded and under
10
- // which key is the part that can leak one project's work into another's view,
11
- // and that must be reviewable without credentials.
12
-
13
- /** Formats the share view will serve. Must match apps/cells/share.mjs. */
14
- const SHAREABLE = /\.(png|jpe?g|webp|avif)$/i;
15
-
16
- /**
17
- * SVG is excluded here for the same reason it is excluded there: an SVG is a
18
- * document that can carry script, and the share origin must never serve one.
19
- * Two lists that could disagree would be a hole, so this test names the reason
20
- * rather than just the extensions.
21
- */
22
- export function isShareable(name) {
23
- return SHAREABLE.test(String(name ?? ''));
24
- }
25
-
26
- /** Same charset the cell entrypoint enforces — the id becomes a storage prefix. */
27
- export function validProjectId(raw) {
28
- const id = String(raw ?? '').trim();
29
- return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) && id.length <= 63 ? id : null;
30
- }
31
-
32
- /**
33
- * What to upload, and where.
34
- *
35
- * @param {string[]} files paths relative to the source directory
36
- * @param {string} project
37
- * @returns {{ uploads: {from: string, key: string}[], skipped: string[] }}
38
- */
39
- export function publishPlan(files, project) {
40
- const id = validProjectId(project);
41
- if (!id) throw new Error(`invalid project id: ${project}`);
42
- const uploads = [];
43
- const skipped = [];
44
- for (const raw of files) {
45
- const rel = String(raw).replace(/\\/g, '/').replace(/^\.\//, '');
46
- // A path that could climb out of the source directory would upload
47
- // something nobody chose to share.
48
- if (rel.startsWith('/') || rel.split('/').includes('..')) {
49
- skipped.push(rel);
50
- continue;
51
- }
52
- if (!isShareable(rel)) {
53
- skipped.push(rel);
54
- continue;
55
- }
56
- uploads.push({ from: rel, key: `tenants/${id}/snapshots/${rel}` });
57
- }
58
- uploads.sort((a, b) => a.key.localeCompare(b.key));
59
- return { uploads, skipped };
60
- }
61
-
62
- /** The marker whose ABSENCE means "not shared". Default-closed by construction. */
63
- export function shareMarker(project, { enabled, name }) {
64
- const id = validProjectId(project);
65
- if (!id) throw new Error(`invalid project id: ${project}`);
66
- return {
67
- key: `tenants/${id}/share.json`,
68
- body: JSON.stringify(
69
- { enabled: Boolean(enabled), name: String(name ?? id).slice(0, 80) },
70
- null,
71
- 2
72
- ),
73
- };
74
- }
75
-
76
- /**
77
- * What the operator is told after a publish.
78
- *
79
- * Says the URL and says what it is NOT, because the single most likely
80
- * misunderstanding is that a shared view is live.
81
- */
82
- export function publishSummary({ project, uploaded, skipped, zone = 'cloud.maude.sh' }) {
83
- const lines = [
84
- `Published ${uploaded} view${uploaded === 1 ? '' : 's'} of ${project}.`,
85
- ``,
86
- ` https://view-${project}.${zone}`,
87
- ``,
88
- `These are pictures taken on this machine just now. They do not update`,
89
- `themselves — publish again after the design changes.`,
90
- ];
91
- if (skipped > 0) {
92
- lines.push(
93
- ``,
94
- `${skipped} file${skipped === 1 ? ' was' : 's were'} not published: the shared view`,
95
- `serves only PNG, JPEG, WebP and AVIF. SVG is excluded on purpose — it can`,
96
- `carry script, and nothing the view serves is ever allowed to execute.`
97
- );
98
- }
99
- return lines.join('\n');
100
- }
@@ -1,66 +0,0 @@
1
- // Cloud Phase 18 — what gets published, and what must never be.
2
-
3
- import assert from 'node:assert/strict';
4
- import test from 'node:test';
5
-
6
- import {
7
- isShareable,
8
- publishPlan,
9
- publishSummary,
10
- shareMarker,
11
- validProjectId,
12
- } from './share-plan.mjs';
13
-
14
- test('SVG is never published, however it is named', () => {
15
- // Same reason the share origin refuses to SERVE one: an SVG is a document
16
- // that can carry <script>. Two lists that could disagree would be a hole,
17
- // so both refuse it and both say why.
18
- for (const bad of ['logo.svg', 'a/b.SVG', 'x.html', 'x.js', 'x.pdf', 'notes.md', 'x.tsx']) {
19
- assert.equal(isShareable(bad), false, bad);
20
- }
21
- for (const ok of ['a.png', 'a.JPG', 'a.jpeg', 'a.webp', 'a.avif']) {
22
- assert.equal(isShareable(ok), true, ok);
23
- }
24
- });
25
-
26
- test('a publish cannot climb out of the directory it was pointed at', () => {
27
- const { uploads, skipped } = publishPlan(
28
- ['../secret.png', '/etc/x.png', 'a/../../b.png', 'ok.png', 'ui/home.png'],
29
- 'acme'
30
- );
31
- assert.deepEqual(
32
- uploads.map((u) => u.from),
33
- ['ok.png', 'ui/home.png']
34
- );
35
- assert.equal(skipped.length, 3);
36
- });
37
-
38
- test('every key is scoped to its own project', () => {
39
- const { uploads } = publishPlan(['home.png'], 'acme');
40
- assert.equal(uploads[0].key, 'tenants/acme/snapshots/home.png');
41
- assert.throws(() => publishPlan(['a.png'], '../other'), /invalid project id/);
42
- assert.throws(() => publishPlan(['a.png'], 'Acme'), /invalid project id/);
43
- assert.equal(validProjectId('a-b-1'), 'a-b-1');
44
- });
45
-
46
- test('the marker is what turns sharing on, and defaults closed', () => {
47
- const on = JSON.parse(shareMarker('acme', { enabled: true, name: 'Acme Co' }).body);
48
- assert.deepEqual(on, { enabled: true, name: 'Acme Co' });
49
- // Anything that is not exactly true is off. The share view treats a missing
50
- // or unparseable marker the same way, so every failure mode is closed.
51
- const off = JSON.parse(shareMarker('acme', { enabled: false }).body);
52
- assert.equal(off.enabled, false);
53
- assert.equal(off.name, 'acme');
54
- });
55
-
56
- test('the summary says what a shared view is NOT', () => {
57
- const out = publishSummary({ project: 'acme', uploaded: 3, skipped: 0 });
58
- assert.match(out, /https:\/\/view-acme\.cloud\.maude\.sh/);
59
- // The single most likely misunderstanding is that this is live.
60
- assert.match(out, /do not update\s*\n?themselves/);
61
- });
62
-
63
- test('a skipped file is explained, not silently dropped', () => {
64
- const out = publishSummary({ project: 'acme', uploaded: 1, skipped: 2 });
65
- assert.match(out, /SVG is excluded on purpose/);
66
- });