@gmickel/gno 1.32.0 → 1.34.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.
Files changed (54) hide show
  1. package/README.md +17 -3
  2. package/assets/skill/SKILL.md +30 -0
  3. package/assets/skill/cli-reference.md +10 -2
  4. package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.34.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.34.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +5 -1
  8. package/spec/cli.md +60 -1
  9. package/spec/mcp.md +21 -0
  10. package/spec/output-schemas/audit-report.schema.json +284 -0
  11. package/spec/output-schemas/publish-artifact.schema.json +76 -1
  12. package/src/cli/commands/audit.ts +231 -0
  13. package/src/cli/commands/publish.ts +43 -7
  14. package/src/cli/errors.ts +9 -2
  15. package/src/cli/program.ts +112 -0
  16. package/src/core/audit-contract.ts +296 -0
  17. package/src/core/audit-freshness.ts +233 -0
  18. package/src/core/audit-links.ts +222 -0
  19. package/src/core/audit-provenance.ts +154 -0
  20. package/src/core/audit-report.ts +318 -0
  21. package/src/core/audit-workspace.ts +678 -0
  22. package/src/core/audit.ts +569 -0
  23. package/src/core/capture.ts +196 -3
  24. package/src/core/document-capabilities.ts +9 -8
  25. package/src/core/record-metadata.ts +33 -0
  26. package/src/ingestion/strip.ts +152 -26
  27. package/src/mcp/http-egress.ts +8 -0
  28. package/src/mcp/tools/audit.ts +97 -0
  29. package/src/mcp/tools/index.ts +13 -0
  30. package/src/publish/artifact-asset-codec.ts +75 -0
  31. package/src/publish/artifact-asset-contract.ts +152 -0
  32. package/src/publish/artifact-asset-parse.ts +401 -0
  33. package/src/publish/artifact-asset-sniff.ts +108 -0
  34. package/src/publish/artifact-asset-validate.ts +209 -0
  35. package/src/publish/artifact-assets.ts +58 -0
  36. package/src/publish/artifact-validation.ts +32 -6
  37. package/src/publish/artifact.ts +50 -3
  38. package/src/publish/attachment-bundle.ts +145 -0
  39. package/src/publish/attachment-discover.ts +203 -0
  40. package/src/publish/attachment-load.ts +133 -0
  41. package/src/publish/attachment-obsidian.ts +45 -0
  42. package/src/publish/attachment-path.ts +334 -0
  43. package/src/publish/attachment-raster.ts +852 -0
  44. package/src/publish/attachment-resolver.ts +280 -0
  45. package/src/publish/attachment-types.ts +54 -0
  46. package/src/publish/encrypted-export.ts +121 -44
  47. package/src/publish/export-attachments.ts +224 -0
  48. package/src/publish/export-service.ts +142 -80
  49. package/src/publish/obsidian-sanitize.ts +121 -13
  50. package/src/serve/routes/api.ts +2 -1
  51. package/src/store/sqlite/adapter.ts +82 -0
  52. package/src/store/sqlite/graph-link-bulk-resolver.ts +191 -0
  53. package/src/store/sqlite/graph-link-resolver.ts +241 -2
  54. package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +0 -1
@@ -0,0 +1,231 @@
1
+ /** Read-only knowledge-integrity audit CLI adapter. */
2
+
3
+ // node:fs/promises provides atomic filesystem structure operations with no Bun equivalent.
4
+ import { chmod, mkdtemp, rename, rmdir, unlink } from "node:fs/promises";
5
+ import { basename, dirname, join } from "node:path";
6
+
7
+ import type {
8
+ AuditCategory,
9
+ AuditReport,
10
+ AuditRunResult,
11
+ } from "../../core/audit";
12
+ import type { WorkspaceAuditProgress } from "../../core/audit-workspace";
13
+
14
+ import { getIndexDbPath } from "../../app/constants";
15
+ import { loadConfig } from "../../config";
16
+ import {
17
+ auditExitCode,
18
+ AUDIT_CATEGORIES,
19
+ serializeAuditReportCanonical,
20
+ } from "../../core/audit";
21
+ import { runWorkspaceAudit } from "../../core/audit-workspace";
22
+ import { normalizeTag, validateTag } from "../../core/tags";
23
+ import { normalizeCollectionName } from "../../core/validation";
24
+ import { SqliteAdapter } from "../../store/sqlite/adapter";
25
+
26
+ export interface AuditCommandOptions {
27
+ category?: string;
28
+ configPath?: string;
29
+ indexName?: string;
30
+ collections?: string[];
31
+ paths?: string[];
32
+ tags?: string[];
33
+ maxFindings?: number;
34
+ maxAgeDays?: number;
35
+ orphanRoots?: string[];
36
+ orphanIgnorePrefixes?: string[];
37
+ signal?: AbortSignal;
38
+ onProgress?: (progress: WorkspaceAuditProgress) => void | Promise<void>;
39
+ }
40
+
41
+ export type AuditCommandResult =
42
+ | { success: true; report: AuditReport; exitCode: number }
43
+ | { success: false; error: string; invalid: boolean };
44
+
45
+ const resolveCategories = (
46
+ value: string | undefined
47
+ ): AuditCategory[] | null => {
48
+ const normalized = (value ?? "all").trim().toLowerCase();
49
+ if (normalized === "all") return [...AUDIT_CATEGORIES];
50
+ return (AUDIT_CATEGORIES as readonly string[]).includes(normalized)
51
+ ? [normalized as AuditCategory]
52
+ : null;
53
+ };
54
+
55
+ const invalidPositiveInteger = (value: number | undefined): boolean =>
56
+ value !== undefined && (!Number.isSafeInteger(value) || value < 1);
57
+
58
+ const AUDIT_SCOPE_FILTER_MAX_ITEMS = 256;
59
+
60
+ const oversizedScopeFilter = (
61
+ options: AuditCommandOptions
62
+ ): "collections" | "paths" | "tags" | null => {
63
+ for (const name of ["collections", "paths", "tags"] as const) {
64
+ if ((options[name]?.length ?? 0) > AUDIT_SCOPE_FILTER_MAX_ITEMS) {
65
+ return name;
66
+ }
67
+ }
68
+ return null;
69
+ };
70
+
71
+ export const audit = async (
72
+ options: AuditCommandOptions
73
+ ): Promise<AuditCommandResult> => {
74
+ const categories = resolveCategories(options.category);
75
+ if (!categories) {
76
+ return {
77
+ success: false,
78
+ invalid: true,
79
+ error: "category must be links, provenance, freshness, or all",
80
+ };
81
+ }
82
+ if (invalidPositiveInteger(options.maxFindings)) {
83
+ return {
84
+ success: false,
85
+ invalid: true,
86
+ error: "maxFindings must be a positive integer",
87
+ };
88
+ }
89
+ if (invalidPositiveInteger(options.maxAgeDays)) {
90
+ return {
91
+ success: false,
92
+ invalid: true,
93
+ error: "maxAgeDays must be a positive integer",
94
+ };
95
+ }
96
+ const oversizedFilter = oversizedScopeFilter(options);
97
+ if (oversizedFilter) {
98
+ return {
99
+ success: false,
100
+ invalid: true,
101
+ error: `${oversizedFilter} must contain at most ${AUDIT_SCOPE_FILTER_MAX_ITEMS} values`,
102
+ };
103
+ }
104
+ const configResult = await loadConfig(options.configPath);
105
+ if (!configResult.ok) {
106
+ return { success: false, invalid: true, error: configResult.error.message };
107
+ }
108
+ const config = configResult.value;
109
+ const requestedCollections = (options.collections ?? []).map(
110
+ normalizeCollectionName
111
+ );
112
+ const requestedTags = (options.tags ?? []).map(normalizeTag);
113
+ const invalidTag = requestedTags.find((tag) => !validateTag(tag));
114
+ if (invalidTag) {
115
+ return {
116
+ success: false,
117
+ invalid: true,
118
+ error: `Invalid tag: "${invalidTag}"`,
119
+ };
120
+ }
121
+ const knownCollections = new Set(
122
+ config.collections.map((collection) => collection.name)
123
+ );
124
+ const missingCollection = requestedCollections.find(
125
+ (collection) => !knownCollections.has(collection)
126
+ );
127
+ if (missingCollection) {
128
+ return {
129
+ success: false,
130
+ invalid: true,
131
+ error: `Collection not found: ${missingCollection}`,
132
+ };
133
+ }
134
+ const dbPath = getIndexDbPath(options.indexName);
135
+ if (!(await Bun.file(dbPath).exists())) {
136
+ return {
137
+ success: false,
138
+ invalid: false,
139
+ error: `Index database not found: ${dbPath}. Run gno index first.`,
140
+ };
141
+ }
142
+ const store = new SqliteAdapter();
143
+ const opened = store.openReadOnly(dbPath);
144
+ if (!opened.ok) {
145
+ return { success: false, invalid: false, error: opened.error.message };
146
+ }
147
+ try {
148
+ const result: AuditRunResult = await runWorkspaceAudit({
149
+ store,
150
+ config,
151
+ collections: config.collections,
152
+ indexName: options.indexName ?? "default",
153
+ categories,
154
+ collectionFilters: requestedCollections,
155
+ pathFilters: options.paths,
156
+ tagFilters: requestedTags,
157
+ maxFindings: options.maxFindings,
158
+ agePolicy:
159
+ options.maxAgeDays === undefined
160
+ ? undefined
161
+ : { maxAgeDays: options.maxAgeDays },
162
+ orphanRoots: options.orphanRoots,
163
+ orphanIgnorePrefixes: options.orphanIgnorePrefixes,
164
+ signal: options.signal,
165
+ onProgress: options.onProgress,
166
+ });
167
+ if (!result.ok) {
168
+ return { success: false, invalid: true, error: result.error };
169
+ }
170
+ return {
171
+ success: true,
172
+ report: result.report,
173
+ exitCode: auditExitCode(result.exit),
174
+ };
175
+ } finally {
176
+ await store.close();
177
+ }
178
+ };
179
+
180
+ export const formatAuditReport = (
181
+ report: AuditReport,
182
+ options: { json?: boolean } = {}
183
+ ): string => {
184
+ if (options.json) return serializeAuditReportCanonical(report);
185
+ const lines = [
186
+ `Audit: ${report.status}`,
187
+ `Categories: ${report.scope.categories.join(", ")}`,
188
+ `Rules: ${report.counts.rules.total} (${report.counts.rules.fail} failed, ${report.counts.rules.unavailable} unavailable, ${report.counts.rules.inconclusive} inconclusive)`,
189
+ `Findings: ${report.counts.findings.total}${report.counts.findings.truncated ? ` (${report.counts.findings.returned} shown)` : ""}`,
190
+ `Examined: ${report.counts.examined.documents} document/rule observations`,
191
+ `Duration: ${report.durationMs}ms`,
192
+ ];
193
+ for (const finding of report.findings) {
194
+ const location = finding.location ? ` ${finding.location}` : "";
195
+ lines.push(
196
+ `- [${finding.severity}] ${finding.ruleId}: ${finding.subject}${location} — ${finding.message}`
197
+ );
198
+ }
199
+ for (const rule of report.rules) {
200
+ if (
201
+ rule.status === "skip" ||
202
+ rule.status === "unavailable" ||
203
+ rule.status === "inconclusive"
204
+ ) {
205
+ lines.push(`- [${rule.status}] ${rule.ruleId}: ${rule.message}`);
206
+ }
207
+ }
208
+ return lines.join("\n");
209
+ };
210
+
211
+ export const writeAuditReport = async (
212
+ path: string,
213
+ report: AuditReport,
214
+ options: { json?: boolean } = {}
215
+ ): Promise<void> => {
216
+ const temporaryDirectory = await mkdtemp(
217
+ join(dirname(path), `.${basename(path)}-`)
218
+ );
219
+ const temporaryPath = join(temporaryDirectory, "report");
220
+ try {
221
+ await Bun.write(temporaryPath, `${formatAuditReport(report, options)}\n`, {
222
+ createPath: false,
223
+ mode: 0o600,
224
+ });
225
+ await chmod(temporaryPath, 0o600);
226
+ await rename(temporaryPath, path);
227
+ } finally {
228
+ await unlink(temporaryPath).catch(() => undefined);
229
+ await rmdir(temporaryDirectory).catch(() => undefined);
230
+ }
231
+ };
@@ -4,7 +4,8 @@
4
4
  * @module src/cli/commands/publish
5
5
  */
6
6
 
7
- import { mkdir, writeFile } from "node:fs/promises";
7
+ // node:fs/promises directory creation has no Bun-native equivalent.
8
+ import { mkdir } from "node:fs/promises";
8
9
  import { dirname } from "node:path";
9
10
  import { join } from "node:path";
10
11
 
@@ -12,10 +13,15 @@ import type {
12
13
  PublishArtifact,
13
14
  PublishVisibility,
14
15
  } from "../../publish/artifact";
16
+ import type { PublishAssetEgressSummary } from "../../publish/attachment-resolver";
15
17
  import type { SanitizeWarning } from "../../publish/obsidian-sanitize";
16
18
 
17
19
  import { resolveDownloadsDir } from "../../core/user-dirs";
18
- import { derivePublishArtifactFilename, slugify } from "../../publish/artifact";
20
+ import {
21
+ derivePublishArtifactFilename,
22
+ serializePublishArtifact,
23
+ slugify,
24
+ } from "../../publish/artifact";
19
25
  import { exportPublishArtifact } from "../../publish/export-service";
20
26
  import { formatSanitizeWarnings } from "../../publish/obsidian-sanitize";
21
27
  import { initStore } from "./shared";
@@ -37,6 +43,7 @@ export type PublishExportResult =
37
43
  success: true;
38
44
  data: {
39
45
  artifact: PublishArtifact;
46
+ assetSummary: PublishAssetEgressSummary;
40
47
  outPath: string;
41
48
  preview?: string;
42
49
  uploadUrl: string;
@@ -64,6 +71,15 @@ export async function buildDefaultPublishExportPath(
64
71
  );
65
72
  }
66
73
 
74
+ /** Write exactly the canonical byte sequence used by upload-size accounting. */
75
+ export async function writePublishArtifactFile(
76
+ outPath: string,
77
+ artifact: PublishArtifact
78
+ ): Promise<void> {
79
+ await mkdir(dirname(outPath), { recursive: true });
80
+ await Bun.write(outPath, serializePublishArtifact(artifact));
81
+ }
82
+
67
83
  export async function publishExport(
68
84
  target: string,
69
85
  options: PublishExportOptions
@@ -79,7 +95,7 @@ export async function publishExport(
79
95
  const { collections, store } = initResult;
80
96
 
81
97
  try {
82
- const { artifact, warnings } = await exportPublishArtifact({
98
+ const { artifact, assetSummary, warnings } = await exportPublishArtifact({
83
99
  collections,
84
100
  options: {
85
101
  routeSlug: options.slug,
@@ -104,6 +120,7 @@ export async function publishExport(
104
120
  success: true,
105
121
  data: {
106
122
  artifact,
123
+ assetSummary,
107
124
  outPath: "",
108
125
  preview,
109
126
  uploadUrl: "https://gno.sh/studio",
@@ -116,13 +133,13 @@ export async function publishExport(
116
133
  const outPath =
117
134
  options.out?.trim() || (await buildDefaultPublishExportPath(artifact));
118
135
 
119
- await mkdir(dirname(outPath), { recursive: true });
120
- await writeFile(outPath, JSON.stringify(artifact, null, 2));
136
+ await writePublishArtifactFile(outPath, artifact);
121
137
 
122
138
  return {
123
139
  success: true,
124
140
  data: {
125
141
  artifact,
142
+ assetSummary,
126
143
  outPath,
127
144
  uploadUrl: "https://gno.sh/studio",
128
145
  warnings,
@@ -159,19 +176,37 @@ export function formatPublishExport(
159
176
  return JSON.stringify(result.data, null, 2);
160
177
  }
161
178
 
162
- const { artifact, outPath, preview, uploadUrl, warningsDisplay } =
163
- result.data;
179
+ const {
180
+ artifact,
181
+ assetSummary,
182
+ outPath,
183
+ preview,
184
+ uploadUrl,
185
+ warningsDisplay,
186
+ } = result.data;
164
187
  const space = artifact.spaces[0];
165
188
  const warningsSection =
166
189
  warningsDisplay.length > 0
167
190
  ? ["", "Preprocessor notes:", ...warningsDisplay]
168
191
  : [];
192
+ const assetSection = [
193
+ "",
194
+ "Asset summary:",
195
+ ` assets=${assetSummary.assetCount} refs=${assetSummary.referenceCount} external=${assetSummary.externalCount}`,
196
+ ` rawBytes=${assetSummary.rawBytes} encodedBytes=${assetSummary.encodedBytes} finalBytes=${assetSummary.finalUploadBytes}`,
197
+ ` dedupSavedBytes=${assetSummary.dedupSavedBytes} unresolved=${assetSummary.diagnostics.length}`,
198
+ ...assetSummary.diagnostics.map(
199
+ (diagnostic) =>
200
+ ` [${diagnostic.code}] ${diagnostic.noteSlug}: ${diagnostic.sourceRef} — ${diagnostic.message}`
201
+ ),
202
+ ];
169
203
 
170
204
  if (preview !== undefined) {
171
205
  return [
172
206
  `Preview (no file written) — ${space?.sourceType ?? "artifact"}`,
173
207
  `Route slug: ${space?.routeSlug ?? slugify(artifact.source)}`,
174
208
  `Visibility: ${space?.visibility ?? "public"}`,
209
+ ...assetSection,
175
210
  ...warningsSection,
176
211
  "",
177
212
  "─── sanitized markdown ───",
@@ -185,6 +220,7 @@ export function formatPublishExport(
185
220
  `Visibility: ${space?.visibility ?? "public"}`,
186
221
  `Filename: ${derivePublishArtifactFilename(artifact)}`,
187
222
  `Next: open ${uploadUrl} and drop ${outPath} into the upload zone.`,
223
+ ...assetSection,
188
224
  ...warningsSection,
189
225
  ].join("\n");
190
226
  }
package/src/cli/errors.ts CHANGED
@@ -9,7 +9,12 @@
9
9
  // Error Types
10
10
  // ─────────────────────────────────────────────────────────────────────────────
11
11
 
12
- export type CliErrorCode = "VALIDATION" | "RUNTIME" | "NOT_RUNNING";
12
+ export type CliErrorCode =
13
+ | "VALIDATION"
14
+ | "RUNTIME"
15
+ | "NOT_RUNNING"
16
+ | "AUDIT_FINDINGS"
17
+ | "AUDIT_PARTIAL";
13
18
 
14
19
  export interface CliErrorOptions {
15
20
  details?: Record<string, unknown>;
@@ -62,13 +67,15 @@ export class CliError extends Error {
62
67
  // Exit Codes
63
68
  // ─────────────────────────────────────────────────────────────────────────────
64
69
 
65
- export function exitCodeFor(err: CliError): 1 | 2 | 3 {
70
+ export function exitCodeFor(err: CliError): 1 | 2 | 3 | 4 | 5 {
66
71
  if (err.code === "VALIDATION") {
67
72
  return 1;
68
73
  }
69
74
  if (err.code === "NOT_RUNNING") {
70
75
  return 3;
71
76
  }
77
+ if (err.code === "AUDIT_FINDINGS") return 4;
78
+ if (err.code === "AUDIT_PARTIAL") return 5;
72
79
  return 2;
73
80
  }
74
81
 
@@ -1539,6 +1539,118 @@ function wireOnboardingCommands(program: Command): void {
1539
1539
  }
1540
1540
  );
1541
1541
 
1542
+ // audit - Read-only workspace integrity report
1543
+ program
1544
+ .command("audit [category]")
1545
+ .description("Run read-only workspace integrity audits")
1546
+ .option(
1547
+ "-c, --collection <name>",
1548
+ "collection scope (repeatable)",
1549
+ collectRepeatableValue,
1550
+ []
1551
+ )
1552
+ .option(
1553
+ "--path <prefix>",
1554
+ "collection-relative path prefix (repeatable)",
1555
+ collectRepeatableValue,
1556
+ []
1557
+ )
1558
+ .option(
1559
+ "--tag <tag>",
1560
+ "require tag (repeatable, AND semantics)",
1561
+ collectRepeatableValue,
1562
+ []
1563
+ )
1564
+ .option("--max-findings <count>", "maximum returned findings", Number)
1565
+ .option("--max-age-days <days>", "explicit age review signal", Number)
1566
+ .option(
1567
+ "--orphan-root <uri>",
1568
+ "URI excluded from orphan findings (repeatable)",
1569
+ collectRepeatableValue,
1570
+ []
1571
+ )
1572
+ .option(
1573
+ "--orphan-ignore-prefix <prefix>",
1574
+ "path prefix excluded from orphan findings (repeatable)",
1575
+ collectRepeatableValue,
1576
+ []
1577
+ )
1578
+ .option("--output <path>", "also write the rendered report to a file")
1579
+ .option("--no-progress", "disable progress on stderr")
1580
+ .option("--json", "JSON output")
1581
+ .action(
1582
+ async (
1583
+ category: string | undefined,
1584
+ cmdOpts: Record<string, unknown>
1585
+ ) => {
1586
+ const { audit, formatAuditReport, writeAuditReport } =
1587
+ await import("./commands/audit");
1588
+ const globals = getGlobals();
1589
+ const json = getFormat(cmdOpts) === "json";
1590
+ const showProgress =
1591
+ cmdOpts.progress !== false &&
1592
+ !globals.quiet &&
1593
+ !json &&
1594
+ process.stderr.isTTY;
1595
+ const controller = new AbortController();
1596
+ const abort = (): void => controller.abort();
1597
+ process.once("SIGINT", abort);
1598
+ try {
1599
+ const result = await audit({
1600
+ category,
1601
+ configPath: globals.config,
1602
+ indexName: globals.index,
1603
+ collections: cmdOpts.collection as string[],
1604
+ paths: cmdOpts.path as string[],
1605
+ tags: cmdOpts.tag as string[],
1606
+ maxFindings: cmdOpts.maxFindings as number | undefined,
1607
+ maxAgeDays: cmdOpts.maxAgeDays as number | undefined,
1608
+ orphanRoots: cmdOpts.orphanRoot as string[],
1609
+ orphanIgnorePrefixes: cmdOpts.orphanIgnorePrefix as string[],
1610
+ signal: controller.signal,
1611
+ onProgress: showProgress
1612
+ ? ({ phase, completed, total }) => {
1613
+ process.stderr.write(
1614
+ `\rAudit ${phase}: ${completed}/${Math.max(total, 1)}`
1615
+ );
1616
+ }
1617
+ : undefined,
1618
+ });
1619
+ if (!result.success) {
1620
+ throw new CliError(
1621
+ result.invalid ? "VALIDATION" : "RUNTIME",
1622
+ result.error
1623
+ );
1624
+ }
1625
+ const rendered = formatAuditReport(result.report, { json });
1626
+ process.stdout.write(`${rendered}\n`);
1627
+ if (cmdOpts.output) {
1628
+ await writeAuditReport(cmdOpts.output as string, result.report, {
1629
+ json,
1630
+ });
1631
+ }
1632
+ if (result.exitCode === 2) {
1633
+ throw new CliError("RUNTIME", "Audit runtime failure", {
1634
+ silent: true,
1635
+ });
1636
+ }
1637
+ if (result.exitCode === 4) {
1638
+ throw new CliError("AUDIT_FINDINGS", "Audit findings present", {
1639
+ silent: true,
1640
+ });
1641
+ }
1642
+ if (result.exitCode === 5) {
1643
+ throw new CliError("AUDIT_PARTIAL", "Audit evidence is partial", {
1644
+ silent: true,
1645
+ });
1646
+ }
1647
+ } finally {
1648
+ if (showProgress) process.stderr.write("\n");
1649
+ process.removeListener("SIGINT", abort);
1650
+ }
1651
+ }
1652
+ );
1653
+
1542
1654
  // status - Show index status
1543
1655
  program
1544
1656
  .command("status")