@1agh/maude 0.49.1 → 0.50.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/apps/studio/api.ts +751 -52
- package/apps/studio/canvas-artifacts.ts +153 -0
- package/apps/studio/canvas-create.ts +23 -0
- package/apps/studio/canvas-slug.ts +30 -0
- package/apps/studio/client/app.jsx +547 -90
- package/apps/studio/client/panels/CloudBar.jsx +284 -0
- package/apps/studio/client/styles/3-shell-maude.css +26 -0
- package/apps/studio/client/tree-row-menu.jsx +132 -0
- package/apps/studio/client/use-tree-drag.js +120 -0
- package/apps/studio/cloud/endpoints.ts +236 -0
- package/apps/studio/collab/registry.ts +23 -0
- package/apps/studio/dist/client.bundle.js +960 -958
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +113 -0
- package/apps/studio/inspect.ts +49 -0
- package/apps/studio/server.ts +15 -0
- package/apps/studio/test/canvas-artifacts.test.ts +125 -0
- package/apps/studio/test/canvas-create-api.test.ts +159 -1
- package/apps/studio/test/canvas-move-api.test.ts +556 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
- package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
- package/apps/studio/whats-new.json +18 -0
- package/cli/bin/maude.mjs +1 -0
- package/cli/commands/hub-workspace.mjs +294 -7
- package/cli/commands/init.mjs +33 -2
- package/cli/commands/kg.mjs +129 -4
- package/cli/commands/share.mjs +184 -0
- package/cli/lib/ddr-to-kgai.mjs +310 -82
- package/cli/lib/ddr-to-kgai.test.mjs +20 -0
- package/cli/lib/share-plan.mjs +96 -0
- package/cli/lib/share-plan.test.mjs +57 -0
- package/cli/lib/workspace-plan.mjs +130 -12
- package/cli/lib/workspace-plan.test.mjs +160 -0
- package/package.json +8 -8
package/cli/commands/init.mjs
CHANGED
|
@@ -195,7 +195,7 @@ export async function run({ args, pkgRoot }) {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
printSummary(result);
|
|
198
|
-
printNextSteps(projectName, claudeMdExists);
|
|
198
|
+
printNextSteps(projectName, claudeMdExists, Boolean(resolveKgBin()), Boolean(flags.kg));
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
/** KGAI_BIN (desktop-staged sidecar) → `kg` on PATH → null. Mirrors kg.mjs. */
|
|
@@ -242,7 +242,7 @@ function printSummary({ created, replaced, skipped }) {
|
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
function printNextSteps(name, claudeMdExists) {
|
|
245
|
+
function printNextSteps(name, claudeMdExists, kgAvailable = false, usedKg = false) {
|
|
246
246
|
process.stdout.write('\nNext steps:\n');
|
|
247
247
|
process.stdout.write(' 1. In Claude Code: /plugin marketplace add 1aGh/maude\n');
|
|
248
248
|
process.stdout.write(' /plugin install flow@maude\n');
|
|
@@ -261,4 +261,35 @@ function printNextSteps(name, claudeMdExists) {
|
|
|
261
261
|
}
|
|
262
262
|
process.stdout.write(` 4. Create .ai/${name}-prd.md with your product brief.\n`);
|
|
263
263
|
process.stdout.write(' 5. /flow:status to see where you are; /flow:plan to start work.\n');
|
|
264
|
+
printKgOffer(kgAvailable, usedKg);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Surface the knowledge-graph choice at scaffold time.
|
|
269
|
+
*
|
|
270
|
+
* Without this, `--kg` is discoverable only by reading `--help` — so a fresh
|
|
271
|
+
* user never learns the option exists at the one moment it is cheapest to take
|
|
272
|
+
* (an empty `.ai/` needs no migration). Deliberately NOT shown when `kg` is
|
|
273
|
+
* absent: advertising a backend the user would first have to go install is
|
|
274
|
+
* noise, and `mode:auto` picks it up by itself if they ever do.
|
|
275
|
+
*/
|
|
276
|
+
function printKgOffer(kgAvailable, usedKg) {
|
|
277
|
+
if (!kgAvailable) return;
|
|
278
|
+
if (usedKg) {
|
|
279
|
+
process.stdout.write(
|
|
280
|
+
'\nkgai: this workspace uses the knowledge graph as its decision memory.\n' +
|
|
281
|
+
' `maude kg doctor` to confirm · `maude kg search "<topic>"` to read it back.\n'
|
|
282
|
+
);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
process.stdout.write(
|
|
286
|
+
'\nkgai: `kg` is installed on this machine, and this workspace was scaffolded\n' +
|
|
287
|
+
' CLASSIC (markdown decisions + STATE.md history). Both work; the graph\n' +
|
|
288
|
+
' makes "what did we decide about X" a query instead of a grep, and gives\n' +
|
|
289
|
+
' the gitignored .ai/logs/ verdicts a copy that travels.\n' +
|
|
290
|
+
' To switch now (empty workspace — nothing to migrate):\n' +
|
|
291
|
+
' maude init --kg --force\n' +
|
|
292
|
+
' Later, once you have decisions on disk, use the migration instead:\n' +
|
|
293
|
+
' maude kg import --dry-run --archive (then drop --dry-run)\n'
|
|
294
|
+
);
|
|
264
295
|
}
|
package/cli/commands/kg.mjs
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
// or an informative message — a command's classic `.ai/` path is unaffected.
|
|
13
13
|
|
|
14
14
|
import { spawnSync } from 'node:child_process';
|
|
15
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
-
import {
|
|
15
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { basename, join, resolve } from 'node:path';
|
|
17
18
|
import { parseArgs } from '../lib/argv.mjs';
|
|
18
19
|
|
|
19
20
|
const CONFIG_PATH = '.ai/workflows.config.json';
|
|
@@ -30,6 +31,7 @@ const VERBS = new Set([
|
|
|
30
31
|
'ingest',
|
|
31
32
|
'scope',
|
|
32
33
|
'import',
|
|
34
|
+
'record-log',
|
|
33
35
|
'help',
|
|
34
36
|
]);
|
|
35
37
|
|
|
@@ -131,15 +133,19 @@ function kgEnv() {
|
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
/** Spawn the resolved `kg` with the given args, inheriting stdio. Returns exit status. */
|
|
134
|
-
function runKg(state, kgArgs, { timeoutMs } = {}) {
|
|
136
|
+
function runKg(state, kgArgs, { timeoutMs, swallowStdout } = {}) {
|
|
135
137
|
if (!state.kgBin) {
|
|
136
138
|
process.stderr.write(
|
|
137
139
|
'maude kg: the `kg` CLI is not available. kgai is capability-gated — install it (see docs/kgai-onboarding.md) or use classic `.ai/` mode.\n'
|
|
138
140
|
);
|
|
139
141
|
return 127;
|
|
140
142
|
}
|
|
143
|
+
// `swallowStdout` is for verbs that print their OWN one-line summary — `kg
|
|
144
|
+
// ingest` dumps a ~25-line JSON receipt per call, and a command that records
|
|
145
|
+
// a verdict on every run would bury the agent's real output in it. stderr
|
|
146
|
+
// still passes through, so a genuine failure is never hidden.
|
|
141
147
|
const child = spawnSync(state.kgBin, kgArgs, {
|
|
142
|
-
stdio: 'inherit',
|
|
148
|
+
stdio: swallowStdout ? ['inherit', 'pipe', 'inherit'] : 'inherit',
|
|
143
149
|
env: kgEnv(),
|
|
144
150
|
...(timeoutMs ? { timeout: timeoutMs } : {}),
|
|
145
151
|
});
|
|
@@ -276,6 +282,117 @@ async function verbImport(state, args, pkgRoot) {
|
|
|
276
282
|
});
|
|
277
283
|
}
|
|
278
284
|
|
|
285
|
+
// ── verb: record-log (keep the graph fed as verdicts are written) ───────────
|
|
286
|
+
//
|
|
287
|
+
// WHY this exists rather than a JSON blob per command: `.ai/logs/**` is
|
|
288
|
+
// GITIGNORED, so for RCAs / reviews / audits the graph is the only inheritable
|
|
289
|
+
// copy. The Phase-5 migration put 120 of them in — but nothing kept feeding it,
|
|
290
|
+
// so the corpus began decaying the moment migration finished. Seven flow
|
|
291
|
+
// commands and six design ones now call this as they write.
|
|
292
|
+
//
|
|
293
|
+
// It delegates to `buildLogDecision`, the SAME builder the bulk importer uses,
|
|
294
|
+
// because a second hand-rolled shape would fork the corpus: a node recorded
|
|
295
|
+
// today has to land on the same shelf as the ones migration created (same slug
|
|
296
|
+
// rule, props, ABOUT/scope/EVIDENCE_FOR edges) or `kg search` returns half an
|
|
297
|
+
// answer. Re-recording is safe — identity is `hash(kind:name)`, props MERGE.
|
|
298
|
+
async function verbRecordLog(state, args, pkgRoot) {
|
|
299
|
+
const { flags } = parseArgs(args, { booleans: ['dry-run', 'quiet'] });
|
|
300
|
+
// Inactive is the COMMON case downstream, and it must be a clean no-op so a
|
|
301
|
+
// command can call this unconditionally instead of re-deriving the gate.
|
|
302
|
+
if (!state.active) return 0;
|
|
303
|
+
|
|
304
|
+
const file = flags.file;
|
|
305
|
+
if (!file) {
|
|
306
|
+
process.stderr.write('maude kg record-log: --file <path> is required.\n');
|
|
307
|
+
return 1;
|
|
308
|
+
}
|
|
309
|
+
const abs = resolve(state.projectRoot, file);
|
|
310
|
+
if (!existsSync(abs)) {
|
|
311
|
+
// A verdict the caller says it wrote but didn't is a caller bug, not a
|
|
312
|
+
// reason to fail the command that produced real work — warn and move on.
|
|
313
|
+
process.stderr.write(`maude kg record-log: no such file: ${abs} — nothing recorded.\n`);
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const libPath = join(pkgRoot, 'cli', 'lib', 'ddr-to-kgai.mjs');
|
|
318
|
+
if (!existsSync(libPath)) {
|
|
319
|
+
process.stderr.write('maude kg record-log: builder unavailable in this build.\n');
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
const { LOG_KINDS, buildLogDecision } = await import(libPath);
|
|
323
|
+
|
|
324
|
+
// Kind: explicit wins; else infer from the parent dir via the SAME table the
|
|
325
|
+
// importer keys on, so `.ai/logs/rca/x.md` → `rca` either way.
|
|
326
|
+
const parent = abs.split('/').slice(-2, -1)[0] ?? '';
|
|
327
|
+
const kind = flags.kind || LOG_KINDS[parent];
|
|
328
|
+
if (!kind) {
|
|
329
|
+
process.stderr.write(
|
|
330
|
+
`maude kg record-log: cannot infer kind from "${parent}/" — pass --kind (known: ${Object.values(
|
|
331
|
+
LOG_KINDS
|
|
332
|
+
).join(', ')}).\n`
|
|
333
|
+
);
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const rel = abs.startsWith(`${state.projectRoot}/`)
|
|
338
|
+
? abs.slice(state.projectRoot.length + 1)
|
|
339
|
+
: abs;
|
|
340
|
+
|
|
341
|
+
// Slug collision guard. Identity is `hash(kind:name)`, so two files sharing a
|
|
342
|
+
// basename become ONE node and the second silently overwrites the first's
|
|
343
|
+
// props — measured: recording `_history/settings/critique/001-PANEL.md` then
|
|
344
|
+
// `_history/login/critique/001-PANEL.md` left a single node pointing at login,
|
|
345
|
+
// with the settings critique gone. Flow logs are safe (their basenames are
|
|
346
|
+
// already unique across `.ai/logs/<kind>/`) and must keep the bare slug to
|
|
347
|
+
// match the migrated corpus — but anything attached to a specific element
|
|
348
|
+
// (`--about canvas:foo`) is per-element by nature, so qualify it with that
|
|
349
|
+
// element's name. Doing it HERE rather than in each caller means six design
|
|
350
|
+
// commands can't each forget it.
|
|
351
|
+
const aboutName = flags.about ? String(flags.about).split(':').slice(1).join(':') : '';
|
|
352
|
+
const derivedSlug =
|
|
353
|
+
flags.slug ||
|
|
354
|
+
(aboutName
|
|
355
|
+
? `${aboutName}-${basename(abs).replace(/\.md$/, '')}`.replace(/\//g, '-')
|
|
356
|
+
: undefined);
|
|
357
|
+
|
|
358
|
+
const built = buildLogDecision(abs, kind, state.scope, {
|
|
359
|
+
pathRel: rel,
|
|
360
|
+
about: flags.about, // e.g. canvas:<slug> for a design verdict
|
|
361
|
+
link: flags.link, // e.g. EVALUATES
|
|
362
|
+
slug: derivedSlug,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
if (flags['dry-run']) {
|
|
366
|
+
process.stdout.write(`${JSON.stringify({ decisions: [built.decision] }, null, 2)}\n`);
|
|
367
|
+
return 0;
|
|
368
|
+
}
|
|
369
|
+
// Temp file + `kg ingest --file`, matching the importer: the same plumbing
|
|
370
|
+
// that ingested 310 decisions during migration, so no new stdin path to get
|
|
371
|
+
// wrong (and a verdict body is far past comfortable argv size anyway).
|
|
372
|
+
const tmp = join(tmpdir(), `kg-record-${kind}-${built.slug}.json`);
|
|
373
|
+
writeFileSync(tmp, JSON.stringify({ decisions: [built.decision] }));
|
|
374
|
+
const status = runKg(state, ['ingest', '--file', tmp], {
|
|
375
|
+
timeoutMs: 30000,
|
|
376
|
+
swallowStdout: true,
|
|
377
|
+
});
|
|
378
|
+
try {
|
|
379
|
+
rmSync(tmp, { force: true });
|
|
380
|
+
} catch {
|
|
381
|
+
/* best-effort temp cleanup */
|
|
382
|
+
}
|
|
383
|
+
if (status !== 0) {
|
|
384
|
+
// Same contract as `sync`: never fail the caller's real work over memory.
|
|
385
|
+
process.stderr.write(`maude kg record-log: ingest failed for ${rel} — the file is on disk.\n`);
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
if (!flags.quiet) {
|
|
389
|
+
process.stdout.write(
|
|
390
|
+
`[kg] recorded ${kind}:${built.slug}${built.citedCount ? ` (${built.citedCount} EVIDENCE_FOR)` : ''}\n`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
return 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
279
396
|
// ── passthrough verbs (context / ingest) ───────────────────────────────────
|
|
280
397
|
function verbPassthrough(verb, state, args) {
|
|
281
398
|
if (!state.active) {
|
|
@@ -311,6 +428,11 @@ function usage() {
|
|
|
311
428
|
ingest <args…> Record a decision + scope tags (passthrough to \`kg ingest\`).
|
|
312
429
|
scope Print the resolved scope ({repo, dept}).
|
|
313
430
|
import [--dry-run …] Migrate .ai/decisions/ + .design/ into kgai (Phase 5).
|
|
431
|
+
record-log --file F Record ONE verdict file (RCA / review / audit / critique) as a
|
|
432
|
+
[--kind K] graph node, shaped exactly like the migrated corpus. Kind is
|
|
433
|
+
[--about E --link L] inferred from the parent dir; --about/--link attach a design
|
|
434
|
+
[--slug S] [--quiet] verdict to canvas:<slug> instead of area:<kind>. Silent no-op
|
|
435
|
+
[--dry-run] when inactive, so callers invoke it unconditionally.
|
|
314
436
|
--root <path> Project root (default $CLAUDE_PROJECT_DIR or cwd).
|
|
315
437
|
|
|
316
438
|
kgai is capability-gated + opt-out. When \`kg\` is absent or mode:off, verbs no-op cleanly
|
|
@@ -357,6 +479,9 @@ export async function run({ args, pkgRoot }) {
|
|
|
357
479
|
case 'import':
|
|
358
480
|
status = await verbImport(state, args.slice(1), pkgRoot);
|
|
359
481
|
break;
|
|
482
|
+
case 'record-log':
|
|
483
|
+
status = await verbRecordLog(state, args.slice(1), pkgRoot);
|
|
484
|
+
break;
|
|
360
485
|
case 'context':
|
|
361
486
|
case 'ingest':
|
|
362
487
|
status = verbPassthrough(verb, state, args);
|
|
@@ -0,0 +1,184 @@
|
|
|
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 = ['MAUDE_R2_ENDPOINT', 'MAUDE_R2_ACCESS_KEY_ID', 'MAUDE_R2_SECRET_ACCESS_KEY'].filter(
|
|
60
|
+
(k) => !env[k]
|
|
61
|
+
);
|
|
62
|
+
if (missing.length) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`object storage is not configured — missing ${missing.join(', ')}.\n` +
|
|
65
|
+
'A publish needs somewhere to put the pictures.'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
endpoint: env.MAUDE_R2_ENDPOINT,
|
|
70
|
+
bucket: env.MAUDE_R2_BUCKET ?? 'maude-cloud-assets',
|
|
71
|
+
accessKeyId: env.MAUDE_R2_ACCESS_KEY_ID,
|
|
72
|
+
secretAccessKey: env.MAUDE_R2_SECRET_ACCESS_KEY,
|
|
73
|
+
region: 'auto',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function loadS3(pkgRoot) {
|
|
78
|
+
for (const candidate of [
|
|
79
|
+
resolve(pkgRoot, 'apps/hub/src/s3.mjs'),
|
|
80
|
+
resolve(pkgRoot, '../apps/hub/src/s3.mjs'),
|
|
81
|
+
]) {
|
|
82
|
+
const mod = await import(`file://${candidate}`).catch(() => null);
|
|
83
|
+
if (mod) return mod;
|
|
84
|
+
}
|
|
85
|
+
throw new Error('the storage client (apps/hub/src/s3.mjs) was not found in this install');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function run({ args, pkgRoot }) {
|
|
89
|
+
const { positional, flags } = parseArgs(args, { booleans: ['help', 'dry-run', 'json'] });
|
|
90
|
+
const sub = positional[0];
|
|
91
|
+
if (flags.help || !sub) {
|
|
92
|
+
process.stdout.write(usage());
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const project = validProjectId(flags.project);
|
|
97
|
+
if (!project) {
|
|
98
|
+
process.stderr.write('maude share: --project is required (lowercase letters, digits, hyphens)\n');
|
|
99
|
+
process.exit(2);
|
|
100
|
+
}
|
|
101
|
+
const zone = String(flags.zone ?? 'cloud.maude.sh');
|
|
102
|
+
|
|
103
|
+
if (sub === 'off') {
|
|
104
|
+
const s3 = await loadS3(pkgRoot);
|
|
105
|
+
const marker = shareMarker(project, { enabled: false, name: flags.name });
|
|
106
|
+
if (flags['dry-run']) {
|
|
107
|
+
process.stdout.write(`Would turn sharing OFF for ${project}.\n`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
await s3.putObject(s3ConfigFromEnv(), marker.key, Buffer.from(marker.body));
|
|
111
|
+
process.stdout.write(
|
|
112
|
+
`Sharing is off for ${project}. The link no longer shows anything.\n` +
|
|
113
|
+
'The pictures are still in storage — nothing serves them.\n'
|
|
114
|
+
);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (sub !== 'publish') {
|
|
119
|
+
process.stderr.write(`maude share: unknown command "${sub}"\n`);
|
|
120
|
+
process.exit(2);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const from = flags.from ? resolve(String(flags.from)) : null;
|
|
124
|
+
if (!from) {
|
|
125
|
+
process.stderr.write('maude share publish: --from <dir> is required\n');
|
|
126
|
+
process.exit(2);
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
if (!statSync(from).isDirectory()) throw new Error('not a directory');
|
|
130
|
+
} catch {
|
|
131
|
+
process.stderr.write(`maude share publish: ${from} is not a directory\n`);
|
|
132
|
+
process.exit(2);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { uploads, skipped } = publishPlan(listFiles(from), project);
|
|
136
|
+
if (uploads.length === 0) {
|
|
137
|
+
process.stderr.write(
|
|
138
|
+
`maude share publish: nothing to publish in ${from}.\n` +
|
|
139
|
+
'The shared view serves PNG, JPEG, WebP and AVIF. Take screenshots first\n' +
|
|
140
|
+
'(`maude design smoke --out-dir <dir>` writes one per canvas).\n'
|
|
141
|
+
);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (flags['dry-run']) {
|
|
146
|
+
process.stdout.write(`Would publish ${uploads.length} file(s) for ${project}:\n`);
|
|
147
|
+
for (const u of uploads.slice(0, 20)) process.stdout.write(` ${u.from} → ${u.key}\n`);
|
|
148
|
+
if (uploads.length > 20) process.stdout.write(` …and ${uploads.length - 20} more\n`);
|
|
149
|
+
if (skipped.length) process.stdout.write(`Would skip ${skipped.length} unshareable file(s).\n`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const s3 = await loadS3(pkgRoot);
|
|
154
|
+
const cfg = s3ConfigFromEnv();
|
|
155
|
+
let uploaded = 0;
|
|
156
|
+
const failed = [];
|
|
157
|
+
for (const u of uploads) {
|
|
158
|
+
try {
|
|
159
|
+
await s3.putObject(cfg, u.key, readFileSync(join(from, u.from)));
|
|
160
|
+
uploaded += 1;
|
|
161
|
+
} catch (err) {
|
|
162
|
+
failed.push({ file: u.from, reason: err.message.slice(0, 120) });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The marker goes LAST. Its presence is what turns sharing on, so writing it
|
|
167
|
+
// first would expose a half-published gallery — the same reasoning as the
|
|
168
|
+
// backup manifest.
|
|
169
|
+
const marker = shareMarker(project, { enabled: true, name: flags.name });
|
|
170
|
+
await s3.putObject(cfg, marker.key, Buffer.from(marker.body));
|
|
171
|
+
|
|
172
|
+
if (flags.json) {
|
|
173
|
+
process.stdout.write(
|
|
174
|
+
`${JSON.stringify({ ok: failed.length === 0, project, uploaded, skipped: skipped.length, failed, url: `https://view-${project}.${zone}` }, null, 2)}\n`
|
|
175
|
+
);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
process.stdout.write(`${publishSummary({ project, uploaded, skipped: skipped.length, zone })}\n`);
|
|
179
|
+
if (failed.length) {
|
|
180
|
+
process.stderr.write(`\n${failed.length} file(s) failed to upload:\n`);
|
|
181
|
+
for (const f of failed.slice(0, 5)) process.stderr.write(` ${f.file} — ${f.reason}\n`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
}
|