@gmickel/gno 1.31.0 → 1.33.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 (77) hide show
  1. package/README.md +15 -5
  2. package/assets/skill/SKILL.md +34 -0
  3. package/assets/skill/cli-reference.md +10 -2
  4. package/browser-extension/artifacts/{gno-browser-clipper-v1.31.0.zip → gno-browser-clipper-v1.33.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.33.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 +35 -1
  9. package/spec/db/schema.sql +55 -0
  10. package/spec/mcp.md +114 -11
  11. package/spec/output-schemas/file-refactor-apply-result.schema.json +305 -0
  12. package/spec/output-schemas/file-refactor-preview.schema.json +393 -0
  13. package/spec/output-schemas/publish-artifact.schema.json +76 -1
  14. package/src/cli/commands/publish.ts +43 -7
  15. package/src/core/document-capabilities.ts +13 -0
  16. package/src/core/file-ops.ts +129 -1
  17. package/src/core/file-refactor-adapter.ts +329 -0
  18. package/src/core/file-refactor-apply-edits.ts +61 -0
  19. package/src/core/file-refactor-apply-fs.ts +512 -0
  20. package/src/core/file-refactor-apply-safety.ts +340 -0
  21. package/src/core/file-refactor-apply-validate.ts +401 -0
  22. package/src/core/file-refactor-contract.ts +486 -0
  23. package/src/core/file-refactor-destination.ts +123 -0
  24. package/src/core/file-refactor-from-snapshot.ts +148 -0
  25. package/src/core/file-refactor-journal-port.ts +150 -0
  26. package/src/core/file-refactor-journal.ts +347 -0
  27. package/src/core/file-refactor-paths.ts +60 -0
  28. package/src/core/file-refactor-plan-classify.ts +208 -0
  29. package/src/core/file-refactor-plan-validate.ts +169 -0
  30. package/src/core/file-refactor-planner-types.ts +62 -0
  31. package/src/core/file-refactor-planner.ts +423 -0
  32. package/src/core/file-refactor-resolve.ts +280 -0
  33. package/src/core/file-refactor-service.ts +468 -0
  34. package/src/core/file-refactors.ts +84 -56
  35. package/src/core/link-destination-parse.ts +275 -0
  36. package/src/core/link-inventory-markdown.ts +454 -0
  37. package/src/core/link-inventory-opaque.ts +244 -0
  38. package/src/core/link-inventory-types.ts +47 -0
  39. package/src/core/link-inventory.ts +182 -0
  40. package/src/core/link-relevance.ts +150 -0
  41. package/src/ingestion/strip.ts +152 -26
  42. package/src/mcp/tools/index.ts +18 -17
  43. package/src/mcp/tools/workspace-write.ts +215 -97
  44. package/src/publish/artifact-asset-codec.ts +75 -0
  45. package/src/publish/artifact-asset-contract.ts +152 -0
  46. package/src/publish/artifact-asset-parse.ts +401 -0
  47. package/src/publish/artifact-asset-sniff.ts +108 -0
  48. package/src/publish/artifact-asset-validate.ts +209 -0
  49. package/src/publish/artifact-assets.ts +58 -0
  50. package/src/publish/artifact-validation.ts +32 -6
  51. package/src/publish/artifact.ts +50 -3
  52. package/src/publish/attachment-bundle.ts +145 -0
  53. package/src/publish/attachment-discover.ts +203 -0
  54. package/src/publish/attachment-load.ts +133 -0
  55. package/src/publish/attachment-obsidian.ts +45 -0
  56. package/src/publish/attachment-path.ts +334 -0
  57. package/src/publish/attachment-raster.ts +852 -0
  58. package/src/publish/attachment-resolver.ts +280 -0
  59. package/src/publish/attachment-types.ts +54 -0
  60. package/src/publish/encrypted-export.ts +121 -44
  61. package/src/publish/export-attachments.ts +224 -0
  62. package/src/publish/export-service.ts +142 -80
  63. package/src/publish/obsidian-sanitize.ts +121 -13
  64. package/src/sdk/client.ts +167 -115
  65. package/src/sdk/index.ts +7 -0
  66. package/src/sdk/types.ts +32 -2
  67. package/src/serve/file-refactor-http.ts +239 -0
  68. package/src/serve/public/components/RefactorImpactPreview.tsx +227 -0
  69. package/src/serve/public/globals.built.css +1 -1
  70. package/src/serve/public/pages/DocView.tsx +176 -41
  71. package/src/serve/routes/api.ts +193 -105
  72. package/src/store/migrations/026-file-refactor-recovery-journal.ts +72 -0
  73. package/src/store/migrations/index.ts +2 -0
  74. package/src/store/sqlite/adapter.ts +452 -0
  75. package/src/store/sqlite/file-refactor-journal-store.ts +275 -0
  76. package/src/store/types.ts +84 -0
  77. package/browser-extension/artifacts/gno-browser-clipper-v1.31.0.zip.sha256 +0 -1
@@ -16,6 +16,10 @@
16
16
  "egressLineage": { "$ref": "#/definitions/egressLineage" },
17
17
  "source": { "$ref": "#/definitions/slug" },
18
18
  "exportedAt": { "type": "string", "format": "date-time" },
19
+ "requiredCapabilities": {
20
+ "$ref": "#/definitions/requiredCapabilities"
21
+ },
22
+ "assets": { "$ref": "#/definitions/assets" },
19
23
  "spaces": {
20
24
  "type": "array",
21
25
  "minItems": 1,
@@ -37,6 +41,9 @@
37
41
  "egressLineage": { "$ref": "#/definitions/egressLineage" },
38
42
  "source": { "$ref": "#/definitions/slug" },
39
43
  "exportedAt": { "type": "string", "format": "date-time" },
44
+ "requiredCapabilities": {
45
+ "$ref": "#/definitions/requiredCapabilities"
46
+ },
40
47
  "spaces": {
41
48
  "type": "array",
42
49
  "minItems": 1,
@@ -44,6 +51,74 @@
44
51
  }
45
52
  }
46
53
  },
54
+ "requiredCapabilities": {
55
+ "type": "array",
56
+ "uniqueItems": true,
57
+ "items": {
58
+ "type": "string",
59
+ "minLength": 1,
60
+ "maxLength": 128
61
+ }
62
+ },
63
+ "assets": {
64
+ "type": "array",
65
+ "items": { "$ref": "#/definitions/artifactAsset" }
66
+ },
67
+ "artifactAsset": {
68
+ "type": "object",
69
+ "additionalProperties": false,
70
+ "required": [
71
+ "id",
72
+ "mediaType",
73
+ "byteLength",
74
+ "sha256",
75
+ "width",
76
+ "height",
77
+ "encoding",
78
+ "data",
79
+ "references"
80
+ ],
81
+ "properties": {
82
+ "id": { "$ref": "#/definitions/hash" },
83
+ "mediaType": {
84
+ "enum": [
85
+ "image/png",
86
+ "image/jpeg",
87
+ "image/gif",
88
+ "image/webp",
89
+ "image/avif"
90
+ ]
91
+ },
92
+ "byteLength": { "type": "integer", "minimum": 1, "maximum": 104857600 },
93
+ "sha256": { "$ref": "#/definitions/hash" },
94
+ "width": { "type": "integer", "minimum": 1, "maximum": 16384 },
95
+ "height": { "type": "integer", "minimum": 1, "maximum": 16384 },
96
+ "encoding": { "const": "base64" },
97
+ "data": {
98
+ "type": "string",
99
+ "minLength": 1,
100
+ "maxLength": 139810134,
101
+ "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$"
102
+ },
103
+ "references": {
104
+ "type": "array",
105
+ "minItems": 1,
106
+ "items": {
107
+ "type": "object",
108
+ "additionalProperties": false,
109
+ "required": ["noteSlug", "sourceRef"],
110
+ "properties": {
111
+ "noteSlug": { "$ref": "#/definitions/slug" },
112
+ "sourceRef": {
113
+ "type": "string",
114
+ "minLength": 1,
115
+ "maxLength": 1024
116
+ }
117
+ }
118
+ }
119
+ }
120
+ }
121
+ },
47
122
  "slug": {
48
123
  "type": "string",
49
124
  "pattern": "^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$",
@@ -280,7 +355,7 @@
280
355
  "ciphertext": {
281
356
  "type": "string",
282
357
  "minLength": 1,
283
- "maxLength": 67108864,
358
+ "maxLength": 104857600,
284
359
  "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$"
285
360
  },
286
361
  "iterations": {
@@ -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
  }
@@ -24,6 +24,19 @@ function isTextLikeMime(mime: string): boolean {
24
24
  return mime.startsWith("text/");
25
25
  }
26
26
 
27
+ /**
28
+ * True when a document may carry markdown/wiki/HTML references in mirror text.
29
+ * Used by refactor inventory completeness to fail closed on missing mirrors.
30
+ * Non-text binaries (pdf/images/…) are omitted — they cannot hold those refs.
31
+ */
32
+ export function isTextLikeReferenceDocument(
33
+ sourceExt: string,
34
+ sourceMime: string
35
+ ): boolean {
36
+ const ext = sourceExt.toLowerCase();
37
+ return EDITABLE_EXTENSIONS.has(ext) || isTextLikeMime(sourceMime);
38
+ }
39
+
27
40
  export function getDocumentCapabilities(input: {
28
41
  sourceExt: string;
29
42
  sourceMime: string;
@@ -27,13 +27,25 @@ export async function atomicWrite(
27
27
  }
28
28
  }
29
29
 
30
+ type AtomicCreateContent =
31
+ | string
32
+ | Blob
33
+ | ArrayBuffer
34
+ | Uint8Array
35
+ | ReturnType<typeof Bun.file>;
36
+
37
+ /**
38
+ * Exclusively create `path` via temp write + hard-link.
39
+ * EEXIST (including a pre-existing symlink) fails closed without following it.
40
+ */
30
41
  export async function atomicCreate(
31
42
  path: string,
32
- content: string
43
+ content: AtomicCreateContent
33
44
  ): Promise<void> {
34
45
  const tempPath = `${path}.tmp.${crypto.randomUUID()}`;
35
46
  await Bun.write(tempPath, content);
36
47
  try {
48
+ // node:fs/promises link — exclusive create; no Bun equivalent
37
49
  await link(tempPath, path);
38
50
  } catch (e) {
39
51
  await unlink(tempPath).catch(() => {
@@ -78,6 +90,122 @@ export async function createFolderPath(path: string): Promise<void> {
78
90
  await mkdir(path, { recursive: true });
79
91
  }
80
92
 
93
+ /** Sibling staging/backup path beside a user file (never under a different root). */
94
+ export function siblingRefactorPath(
95
+ path: string,
96
+ kind: "stage" | "backup",
97
+ token: string
98
+ ): string {
99
+ return `${path}.gno-rf-${kind}.${token}`;
100
+ }
101
+
102
+ /**
103
+ * Exclusively create a stage artifact. Pre-existing symlink/file at stagePath
104
+ * fails closed (EEXIST) and is never followed/overwritten.
105
+ */
106
+ export async function writeStagedFileContent(
107
+ stagePath: string,
108
+ content: string
109
+ ): Promise<void> {
110
+ await atomicCreate(stagePath, content);
111
+ }
112
+
113
+ /**
114
+ * Exclusively copy live bytes into a sibling backup path.
115
+ * Pre-existing symlink/file at backupPath fails closed (EEXIST).
116
+ */
117
+ export async function backupFileToSibling(
118
+ sourcePath: string,
119
+ backupPath: string
120
+ ): Promise<void> {
121
+ await atomicCreate(backupPath, Bun.file(sourcePath));
122
+ }
123
+
124
+ /**
125
+ * Replace an existing path from a staged sibling via rename.
126
+ * Callers must verify live fingerprints before invoking.
127
+ */
128
+ export async function commitStagedFileReplace(
129
+ stagePath: string,
130
+ targetPath: string
131
+ ): Promise<void> {
132
+ await rename(stagePath, targetPath);
133
+ }
134
+
135
+ /**
136
+ * Create a new target from a staged sibling without overwrite.
137
+ * Uses hard-link create semantics so an occupied target fails closed.
138
+ */
139
+ export async function commitStagedFileExclusive(
140
+ stagePath: string,
141
+ targetPath: string
142
+ ): Promise<void> {
143
+ // node:fs/promises link — exclusive create; no Bun equivalent
144
+ await link(stagePath, targetPath);
145
+ await unlink(stagePath).catch(() => {
146
+ /* stage cleanup best-effort after durable target exists */
147
+ });
148
+ }
149
+
150
+ /** @deprecated Prefer commitStagedFileReplace / commitStagedFileExclusive. */
151
+ export async function commitStagedFile(
152
+ stagePath: string,
153
+ targetPath: string
154
+ ): Promise<void> {
155
+ await commitStagedFileReplace(stagePath, targetPath);
156
+ }
157
+
158
+ /**
159
+ * Restore original bytes by replacing the target path itself (rename),
160
+ * never following a symlink that may have been swapped onto the target.
161
+ */
162
+ export async function restoreFileFromBackup(
163
+ backupPath: string,
164
+ targetPath: string
165
+ ): Promise<void> {
166
+ const tempPath = `${targetPath}.gno-rf-restore.${crypto.randomUUID()}`;
167
+ await Bun.write(tempPath, Bun.file(backupPath));
168
+ try {
169
+ // node:fs/promises rename — replaces the directory entry (symlink-safe)
170
+ await rename(tempPath, targetPath);
171
+ } catch (error) {
172
+ await unlink(tempPath).catch(() => {
173
+ /* ignore cleanup errors */
174
+ });
175
+ throw error;
176
+ }
177
+ }
178
+
179
+ function isEnoent(error: unknown): boolean {
180
+ return (
181
+ error instanceof Error &&
182
+ "code" in error &&
183
+ (error as NodeJS.ErrnoException).code === "ENOENT"
184
+ );
185
+ }
186
+
187
+ /** Best-effort cleanup removal; ignores all errors (never used for commit). */
188
+ export async function removePathIfExists(path: string): Promise<void> {
189
+ // node:fs/promises unlink — structure op, no Bun equivalent
190
+ await unlink(path).catch(() => {
191
+ /* ignore cleanup errors */
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Required removal: ignores only ENOENT. Permission/I/O failures throw so
197
+ * callers cannot report success with both source and target present.
198
+ */
199
+ export async function removePathRequired(path: string): Promise<void> {
200
+ // node:fs/promises unlink — structure op, no Bun equivalent
201
+ try {
202
+ await unlink(path);
203
+ } catch (error) {
204
+ if (isEnoent(error)) return;
205
+ throw error;
206
+ }
207
+ }
208
+
81
209
  type TrashFileDeps = {
82
210
  homeDir?: string;
83
211
  platform?: ReturnType<typeof getPlatform>;