@moxn/kb-migrate 0.4.41 → 0.6.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/dist/export.js CHANGED
@@ -45,6 +45,75 @@ function mediaTargetForDirective(directive, options) {
45
45
  function docPathToFilePath(docPath) {
46
46
  return `${docPath.replace(/^\//, '')}.md`;
47
47
  }
48
+ /** A KB path → its directory-relative location with a given extension. */
49
+ function docPathWithExt(docPath, ext) {
50
+ return `${docPath.replace(/^\//, '')}${ext}`;
51
+ }
52
+ /** A document that could not be exported for a reason that is not an error. */
53
+ class SkipExport extends Error {
54
+ }
55
+ /**
56
+ * Export a document the grammar export has no body for (report / file /
57
+ * slides), via the `read` tool. Returns the directory-relative files written
58
+ * (the first is the document's own output file). Throws on a read error
59
+ * (→ failed) and {@link SkipExport} on a kind it does not know (→ skipped),
60
+ * so nothing is ever reported "exported" without its body on disk.
61
+ */
62
+ async function exportNonMarkdownDocument(client, docPath, documentId, outputDir, dryRun, claimed) {
63
+ const item = await client.readDocument(documentId);
64
+ if (item.error)
65
+ throw new Error(item.error);
66
+ const write = (relPath, text) => {
67
+ if (dryRun)
68
+ return;
69
+ const full = path.join(outputDir, relPath);
70
+ fs.mkdirSync(path.dirname(full), { recursive: true });
71
+ fs.writeFileSync(full, text, 'utf-8');
72
+ };
73
+ const htmlBody = () => {
74
+ const html = item.content?.find((c) => typeof c.text === 'string')?.text;
75
+ if (html === undefined)
76
+ throw new Error(`read returned no ${item.kind} HTML`);
77
+ return html;
78
+ };
79
+ switch (item.kind) {
80
+ case 'report': {
81
+ const out = docPathWithExt(docPath, '.html');
82
+ write(out, htmlBody());
83
+ return [out];
84
+ }
85
+ case 'slides': {
86
+ const out = docPathWithExt(docPath, '.html');
87
+ write(out, htmlBody());
88
+ const themeCss = item.deck?.themeCss;
89
+ if (!themeCss)
90
+ return [out];
91
+ const theme = docPathWithExt(docPath, '.theme.css');
92
+ write(theme, themeCss);
93
+ return [out, theme];
94
+ }
95
+ case 'file': {
96
+ if (!item.file)
97
+ throw new Error('read returned no file metadata');
98
+ // The blob under its ORIGINAL filename, beside where the doc lives. The
99
+ // filename is server data: only its basename is used, and a name another
100
+ // document in this run already took falls back to the (unique) KB path.
101
+ const folder = path.posix.dirname(docPath.replace(/^\//, ''));
102
+ const base = path.basename(item.file.filename);
103
+ let out = base && base !== '..' ? path.posix.join(folder, base) : '';
104
+ if (!out || claimed.has(out))
105
+ out = docPath.replace(/^\//, '');
106
+ if (!dryRun) {
107
+ const full = path.join(outputDir, out);
108
+ fs.mkdirSync(path.dirname(full), { recursive: true });
109
+ await client.downloadFile(item.file.download.url, full);
110
+ }
111
+ return [out];
112
+ }
113
+ default:
114
+ throw new SkipExport(`kind '${item.kind ?? 'unknown'}' has no local export format; nothing written`);
115
+ }
116
+ }
48
117
  // ──────────────────────────────────────────────
49
118
  // Main export runner
50
119
  // ──────────────────────────────────────────────
@@ -84,6 +153,7 @@ export async function runExport(outputDir, options) {
84
153
  total: 0,
85
154
  exported: 0,
86
155
  failed: 0,
156
+ skipped: 0,
87
157
  mediaDownloaded: 0,
88
158
  duration: Date.now() - startTime,
89
159
  },
@@ -98,6 +168,9 @@ export async function runExport(outputDir, options) {
98
168
  }
99
169
  const results = [];
100
170
  let totalMediaDownloaded = 0;
171
+ // Output files written so far — a file blob whose original filename is
172
+ // already taken falls back to its KB path instead of overwriting.
173
+ const claimed = new Set();
101
174
  for (const docItem of documents) {
102
175
  const docStart = Date.now();
103
176
  const mediaFiles = [];
@@ -115,7 +188,21 @@ export async function runExport(outputDir, options) {
115
188
  // and the kb-import-markdown round-trip test (both use the clean export).
116
189
  const doc = await client.getDocumentMarkdown(docItem.id);
117
190
  if (!doc) {
118
- throw new Error('document is not a grammar-markdown document (no body)');
191
+ // No grammar body: a report / file / slides document. Export its real
192
+ // body (never an empty `.md` stand-in).
193
+ const files = await exportNonMarkdownDocument(client, docItem.path, docItem.id, outputDir, options.dryRun, claimed);
194
+ files.forEach((f) => claimed.add(f));
195
+ console.error(` ✓ ${files[0]}`);
196
+ results.push({
197
+ documentId: docItem.id,
198
+ documentPath: docItem.path,
199
+ outputFile: files[0],
200
+ status: 'exported',
201
+ sectionsCount: 0,
202
+ mediaFiles: files.slice(1),
203
+ duration: Date.now() - docStart,
204
+ });
205
+ continue;
119
206
  }
120
207
  const mdRelativePath = docPathToFilePath(docItem.path);
121
208
  const mdFullPath = path.join(outputDir, mdRelativePath);
@@ -171,6 +258,7 @@ export async function runExport(outputDir, options) {
171
258
  fs.mkdirSync(path.dirname(mdFullPath), { recursive: true });
172
259
  fs.writeFileSync(mdFullPath, markdown, 'utf-8');
173
260
  }
261
+ claimed.add(mdRelativePath);
174
262
  console.error(` ✓ ${mdRelativePath} (${mediaFiles.length} media)`);
175
263
  results.push({
176
264
  documentId: docItem.id,
@@ -186,12 +274,13 @@ export async function runExport(outputDir, options) {
186
274
  }
187
275
  catch (error) {
188
276
  const message = error instanceof Error ? error.message : String(error);
189
- console.error(` ✗ Failed: ${message}`);
277
+ const skipped = error instanceof SkipExport;
278
+ console.error(` ${skipped ? '-' : '✗'} ${skipped ? 'Skipped' : 'Failed'}: ${message}`);
190
279
  results.push({
191
280
  documentId: docItem.id,
192
281
  documentPath: docItem.path,
193
- outputFile: docPathToFilePath(docItem.path),
194
- status: 'failed',
282
+ outputFile: '',
283
+ status: skipped ? 'skipped' : 'failed',
195
284
  sectionsCount: 0,
196
285
  mediaFiles: [],
197
286
  error: message,
@@ -215,6 +304,7 @@ export async function runExport(outputDir, options) {
215
304
  total: results.length,
216
305
  exported: results.filter((r) => r.status === 'exported').length,
217
306
  failed: results.filter((r) => r.status === 'failed').length,
307
+ skipped: results.filter((r) => r.status === 'skipped').length,
218
308
  mediaDownloaded: totalMediaDownloaded,
219
309
  duration: Date.now() - startTime,
220
310
  },
@@ -105,3 +105,124 @@ describe('runExport — clean grammar (no {#id} anchors, Issue 1)', () => {
105
105
  fs.rmSync(outputDir, { recursive: true, force: true });
106
106
  });
107
107
  });
108
+ /**
109
+ * Non-markdown kinds (prod 2026-09-25). A `kind='report'` document came out
110
+ * as a 34-byte `.md` holding only `# <name>` while the summary said
111
+ * `exported: 2, failed: 0` — its HTML body silently gone. The grammar export
112
+ * (`get_document_markdown`) has no body for report / file / slides, so each
113
+ * kind now exports its REAL body via the `read` tool, and anything that can't
114
+ * be exported is `skipped` or `failed` with a reason — never "exported".
115
+ */
116
+ describe('runExport — non-markdown kinds export their real body', () => {
117
+ afterEach(() => {
118
+ vi.restoreAllMocks();
119
+ });
120
+ const OPTIONS = {
121
+ apiUrl: 'http://localhost:3001',
122
+ apiKey: 'test-key',
123
+ basePath: '',
124
+ imageDir: 'images',
125
+ pdfDir: 'pdfs',
126
+ csvDir: 'csvs',
127
+ dryRun: false,
128
+ };
129
+ function listItem(id, docPath) {
130
+ return {
131
+ id,
132
+ path: docPath,
133
+ name: docPath.split('/').pop(),
134
+ description: null,
135
+ createdAt: '2026-01-01T00:00:00.000Z',
136
+ updatedAt: null,
137
+ };
138
+ }
139
+ it('writes report HTML, the file blob under its filename, and deck HTML; md stays grammar', async () => {
140
+ const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-export-kinds-'));
141
+ vi.spyOn(MoxnClient.prototype, 'listDocuments').mockResolvedValue([
142
+ listItem('md-1', '/qa/notes'),
143
+ listItem('rep-1', '/qa/dashboard'),
144
+ listItem('file-1', '/qa/file-versions.txt'),
145
+ listItem('deck-1', '/qa/pitch'),
146
+ ]);
147
+ vi.spyOn(MoxnClient.prototype, 'getDocumentMarkdown').mockImplementation(async (id) => id === 'md-1'
148
+ ? {
149
+ id,
150
+ branchName: 'main',
151
+ markdown: '---\nname: notes\n---\n\nPreamble.\n\n## Alpha\n\nalpha\n',
152
+ }
153
+ : null);
154
+ const readDocument = vi
155
+ .spyOn(MoxnClient.prototype, 'readDocument')
156
+ .mockImplementation(async (id) => {
157
+ if (id === 'rep-1') {
158
+ return {
159
+ type: 'document',
160
+ documentId: id,
161
+ kind: 'report',
162
+ content: [{ type: 'text', text: '<h1>Q3</h1><p>numbers</p>' }],
163
+ };
164
+ }
165
+ if (id === 'file-1') {
166
+ return {
167
+ type: 'document',
168
+ documentId: id,
169
+ kind: 'file',
170
+ file: {
171
+ filename: 'f.txt',
172
+ download: { url: 'https://storage.example/signed-f' },
173
+ },
174
+ };
175
+ }
176
+ return {
177
+ type: 'document',
178
+ documentId: id,
179
+ kind: 'slides',
180
+ content: [{ type: 'text', text: '<section data-moxn-slide>one</section>' }],
181
+ deck: { themeCss: null },
182
+ };
183
+ });
184
+ vi.spyOn(MoxnClient.prototype, 'downloadFile').mockImplementation(async (url, dest) => {
185
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
186
+ fs.writeFileSync(dest, `blob from ${url}`);
187
+ });
188
+ const log = await runExport(outputDir, OPTIONS);
189
+ // md never touches the read fallback.
190
+ expect(readDocument.mock.calls.map((c) => c[0]).sort()).toEqual([
191
+ 'deck-1',
192
+ 'file-1',
193
+ 'rep-1',
194
+ ]);
195
+ expect(fs.readFileSync(path.join(outputDir, 'qa/notes.md'), 'utf-8')).toContain('Preamble.');
196
+ expect(fs.readFileSync(path.join(outputDir, 'qa/dashboard.html'), 'utf-8')).toBe('<h1>Q3</h1><p>numbers</p>');
197
+ expect(fs.readFileSync(path.join(outputDir, 'qa/f.txt'), 'utf-8')).toBe('blob from https://storage.example/signed-f');
198
+ expect(fs.readFileSync(path.join(outputDir, 'qa/pitch.html'), 'utf-8')).toBe('<section data-moxn-slide>one</section>');
199
+ // No empty `.md` stand-ins for the non-md kinds.
200
+ expect(fs.existsSync(path.join(outputDir, 'qa/dashboard.md'))).toBe(false);
201
+ const byId = Object.fromEntries(log.results.map((r) => [r.documentId, r]));
202
+ expect(byId['rep-1'].outputFile).toBe('qa/dashboard.html');
203
+ expect(byId['file-1'].outputFile).toBe('qa/f.txt');
204
+ expect(byId['deck-1'].outputFile).toBe('qa/pitch.html');
205
+ expect(log.summary).toMatchObject({ total: 4, exported: 4, failed: 0, skipped: 0 });
206
+ fs.rmSync(outputDir, { recursive: true, force: true });
207
+ });
208
+ it('a read error is a FAILURE with its reason, an unknown kind is SKIPPED — neither is "exported"', async () => {
209
+ const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-export-kinds-err-'));
210
+ vi.spyOn(MoxnClient.prototype, 'listDocuments').mockResolvedValue([
211
+ listItem('locked', '/qa/locked'),
212
+ listItem('odd', '/qa/odd'),
213
+ ]);
214
+ vi.spyOn(MoxnClient.prototype, 'getDocumentMarkdown').mockResolvedValue(null);
215
+ vi.spyOn(MoxnClient.prototype, 'readDocument').mockImplementation(async (id) => id === 'locked'
216
+ ? { type: 'document', documentId: id, error: 'Access denied (aiAccess none)' }
217
+ : { type: 'document', documentId: id, kind: 'hologram' });
218
+ const log = await runExport(outputDir, OPTIONS);
219
+ const byId = Object.fromEntries(log.results.map((r) => [r.documentId, r]));
220
+ expect(byId.locked.status).toBe('failed');
221
+ expect(byId.locked.error).toContain('Access denied');
222
+ expect(byId.odd.status).toBe('skipped');
223
+ expect(byId.odd.error).toMatch(/hologram/);
224
+ expect(log.summary).toMatchObject({ total: 2, exported: 0, failed: 1, skipped: 1 });
225
+ expect(fs.readdirSync(outputDir).filter((f) => f === 'qa')).toEqual([]);
226
+ fs.rmSync(outputDir, { recursive: true, force: true });
227
+ });
228
+ });
package/dist/http.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function moxnFetch(url: string, init?: RequestInit): Promise<Response>;
package/dist/http.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The one fetch for Moxn API calls. Adds the Vercel protection-bypass header
3
+ * via @moxn/auth's `vercelBypassHeaders` — the helper kb-cli uses — which is a
4
+ * no-op unless MOXN_VERCEL_BYPASS is set AND the URL is MOXN_BASE_URL's
5
+ * origin, so signed storage / Notion / media URLs never receive the secret.
6
+ */
7
+ import { vercelBypassHeaders } from '@moxn/auth';
8
+ export function moxnFetch(url, init = {}) {
9
+ const bypass = vercelBypassHeaders(url);
10
+ if (Object.keys(bypass).length === 0)
11
+ return fetch(url, init);
12
+ const headers = new Headers(init.headers);
13
+ for (const [k, v] of Object.entries(bypass))
14
+ headers.set(k, v);
15
+ return fetch(url, { ...init, headers });
16
+ }
@@ -206,6 +206,7 @@ async function migrateGrammarFile(client, file, options) {
206
206
  documentPath: res.path || documentPath,
207
207
  status,
208
208
  documentId: res.id,
209
+ ...client.permissionWarningFor(res, file.sourcePath),
209
210
  duration: Date.now() - startTime,
210
211
  },
211
212
  hadFmWarning,
@@ -117,3 +117,99 @@ describe('runLocalGrammarMigration — FM warning surface + summary count', () =
117
117
  expect(log.summary.fmIgnoredOrDegraded).toBeUndefined();
118
118
  });
119
119
  });
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+ // --default-permission / --ai-access reach the server (prod 2026-09-25: both
122
+ // were stored on the client and never sent, so `--ai-access none` created an
123
+ // ai_access='edit' document).
124
+ // ─────────────────────────────────────────────────────────────────────────────
125
+ describe('runLocalGrammarMigration — importer permissions', () => {
126
+ let warnSpy;
127
+ let fetchSpy;
128
+ beforeEach(() => {
129
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
130
+ vi.spyOn(console, 'log').mockImplementation(() => { });
131
+ fetchSpy = vi.spyOn(globalThis, 'fetch');
132
+ });
133
+ afterEach(() => {
134
+ vi.restoreAllMocks();
135
+ });
136
+ const PERM_OPTIONS = {
137
+ ...BASE_OPTIONS,
138
+ defaultPermission: 'read',
139
+ aiAccess: 'none',
140
+ };
141
+ function sentBody() {
142
+ const init = fetchSpy.mock.calls[0][1];
143
+ return JSON.parse(String(init.body));
144
+ }
145
+ it('sends defaultPermission + aiAccess on the import_markdown request', async () => {
146
+ fetchSpy.mockResolvedValue(mockResponse(200, {
147
+ result: {
148
+ id: 'doc-p',
149
+ path: '/imported/my-doc',
150
+ outcome: 'created',
151
+ existed: false,
152
+ name: 'my-doc',
153
+ frontMatterIgnored: false,
154
+ permissions: { defaultPermission: 'read', aiAccess: 'none' },
155
+ },
156
+ }));
157
+ const log = await runLocalGrammarMigration(fakeSource(), PERM_OPTIONS);
158
+ const body = sentBody();
159
+ expect(body.action).toBe('import_markdown');
160
+ expect(body.defaultPermission).toBe('read');
161
+ expect(body.aiAccess).toBe('none');
162
+ expect(log.results[0].status).toBe('created');
163
+ // Applied as asked → nothing to warn about.
164
+ expect(warnSpy).not.toHaveBeenCalled();
165
+ });
166
+ it('omits both fields when the flags were not given (server keeps its defaults)', async () => {
167
+ fetchSpy.mockResolvedValue(mockResponse(200, {
168
+ result: {
169
+ id: 'doc-d',
170
+ path: '/imported/my-doc',
171
+ outcome: 'created',
172
+ existed: false,
173
+ name: 'my-doc',
174
+ frontMatterIgnored: false,
175
+ permissions: { defaultPermission: 'read', aiAccess: 'edit' },
176
+ },
177
+ }));
178
+ await runLocalGrammarMigration(fakeSource(), BASE_OPTIONS);
179
+ const body = sentBody();
180
+ expect('defaultPermission' in body).toBe(false);
181
+ expect('aiAccess' in body).toBe(false);
182
+ });
183
+ it('warns that an UPDATED document kept its existing permissions', async () => {
184
+ fetchSpy.mockResolvedValue(mockResponse(200, {
185
+ result: {
186
+ id: 'doc-u',
187
+ path: '/imported/my-doc',
188
+ outcome: 'updated',
189
+ existed: true,
190
+ name: 'my-doc',
191
+ frontMatterIgnored: false,
192
+ },
193
+ }));
194
+ const log = await runLocalGrammarMigration(fakeSource(), PERM_OPTIONS);
195
+ expect(log.results[0].status).toBe('updated');
196
+ expect(log.results[0].warning).toMatch(/permissions/i);
197
+ expect(String(warnSpy.mock.calls[0]?.[0])).toMatch(/existing document.*permissions/i);
198
+ });
199
+ it('warns when a server too old to apply the flags created the document without them', async () => {
200
+ fetchSpy.mockResolvedValue(mockResponse(200, {
201
+ result: {
202
+ id: 'doc-o',
203
+ path: '/imported/my-doc',
204
+ outcome: 'created',
205
+ existed: false,
206
+ name: 'my-doc',
207
+ frontMatterIgnored: false,
208
+ // no `permissions` echo — an older server ignored the fields
209
+ },
210
+ }));
211
+ const log = await runLocalGrammarMigration(fakeSource(), PERM_OPTIONS);
212
+ expect(log.results[0].warning).toMatch(/server/i);
213
+ expect(String(warnSpy.mock.calls[0]?.[0])).toMatch(/did not apply/i);
214
+ });
215
+ });
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { notionColorToHex } from './sources/notion-api.js';
19
19
  import { getPageTitle } from './sources/notion-blocks.js';
20
20
  import { slugify } from './sources/notion.js';
21
21
  import { MoxnClient } from './client.js';
22
+ import { emitJson, enterJsonMode, exitJsonMode, packageVersion } from './output.js';
22
23
  import { runExport } from './export.js';
23
24
  import { runLocalGrammarMigration } from './import-local.js';
24
25
  import { runNotionExport } from './export-notion.js';
@@ -121,6 +122,18 @@ async function runMigration(source, options) {
121
122
  };
122
123
  return log;
123
124
  }
125
+ /** Exit on a --default-permission / --ai-access value the server would reject per document. */
126
+ function assertPermissionFlags(opts) {
127
+ for (const [flag, value] of [
128
+ ['--default-permission', opts.defaultPermission],
129
+ ['--ai-access', opts.aiAccess],
130
+ ]) {
131
+ if (value !== undefined && !['edit', 'read', 'none'].includes(value)) {
132
+ console.error(`Error: ${flag} must be "edit", "read" or "none"`);
133
+ process.exit(1);
134
+ }
135
+ }
136
+ }
124
137
  function printSummary(log) {
125
138
  console.log('\n--- Migration Summary ---');
126
139
  console.log(`Source: ${log.source.type} (${log.source.location})`);
@@ -159,10 +172,14 @@ function printExportSummary(log) {
159
172
  console.log(`Total: ${log.summary.total}`);
160
173
  console.log(`Exported: ${log.summary.exported}`);
161
174
  console.log(`Failed: ${log.summary.failed}`);
175
+ console.log(`Skipped: ${log.summary.skipped}`);
162
176
  console.log(`Media: ${log.summary.mediaDownloaded}`);
163
- if (log.summary.failed > 0) {
164
- console.log('\nFailed documents:');
165
- for (const f of log.results.filter((r) => r.status === 'failed')) {
177
+ for (const status of ['failed', 'skipped']) {
178
+ const rows = log.results.filter((r) => r.status === status);
179
+ if (rows.length === 0)
180
+ continue;
181
+ console.log(`\n${status === 'failed' ? 'Failed' : 'Skipped'} documents:`);
182
+ for (const f of rows) {
166
183
  console.log(` - ${f.documentPath}: ${f.error}`);
167
184
  }
168
185
  }
@@ -276,7 +293,15 @@ const program = new Command();
276
293
  program
277
294
  .name('moxn-kb-migrate')
278
295
  .description('Import documents into Moxn Knowledge Base')
279
- .version('0.1.0');
296
+ .version(packageVersion());
297
+ // Which kb-migrate ran is always on stderr (a stale npx resolution once ran a
298
+ // retired exporter silently), and `--json` keeps stdout to the JSON log alone.
299
+ program.hook('preAction', (_program, actionCommand) => {
300
+ console.error(`@moxn/kb-migrate ${packageVersion()}`);
301
+ if (actionCommand.opts().json)
302
+ enterJsonMode();
303
+ });
304
+ program.hook('postAction', () => exitJsonMode());
280
305
  program
281
306
  .command('local <directory>')
282
307
  .description('Migrate documents from local filesystem')
@@ -285,8 +310,8 @@ program
285
310
  .option('--base-path <path>', 'Base path for imported documents', '/')
286
311
  .option('--extensions <exts>', 'Comma-separated file extensions', DEFAULT_EXTENSIONS.join(','))
287
312
  .option('--on-conflict <action>', 'Action on conflict: skip or update', 'skip')
288
- .option('--default-permission <perm>', 'Default permission: edit, read, or none')
289
- .option('--ai-access <perm>', 'AI access permission: edit, read, or none')
313
+ .option('--default-permission <perm>', 'Default permission for CREATED docs: edit, read, or none (an update leaves existing permissions alone)')
314
+ .option('--ai-access <perm>', 'AI access for CREATED docs: edit, read, or none (an update leaves existing permissions alone)')
290
315
  .option('--created-after <date>', 'Only include docs created after this date (ISO 8601)')
291
316
  .option('--created-before <date>', 'Only include docs created before this date (ISO 8601)')
292
317
  .option('--modified-after <date>', 'Only include docs modified after this date (ISO 8601)')
@@ -305,6 +330,7 @@ program
305
330
  console.error('Error: --on-conflict must be "skip" or "update"');
306
331
  process.exit(1);
307
332
  }
333
+ assertPermissionFlags(opts);
308
334
  const dateFilter = buildDateFilter({
309
335
  createdAfter: opts.createdAfter,
310
336
  createdBefore: opts.createdBefore,
@@ -333,7 +359,7 @@ program
333
359
  // create_document. Notion/OneNote stay on the blocks runMigration path.
334
360
  const log = await runLocalGrammarMigration(source, migrationOptions);
335
361
  if (opts.json) {
336
- console.log(JSON.stringify(log, null, 2));
362
+ emitJson(log);
337
363
  }
338
364
  else {
339
365
  printSummary(log);
@@ -388,7 +414,7 @@ program
388
414
  try {
389
415
  const log = await runExport(directory, exportOptions);
390
416
  if (opts.json) {
391
- console.log(JSON.stringify(log, null, 2));
417
+ emitJson(log);
392
418
  }
393
419
  else {
394
420
  printExportSummary(log);
@@ -412,8 +438,8 @@ program
412
438
  .option('--root-page-id <id>', 'Import subtree starting from this Notion page ID')
413
439
  .option('--max-depth <n>', 'Maximum nesting depth')
414
440
  .option('--on-conflict <action>', 'Action on conflict: skip or update', 'skip')
415
- .option('--default-permission <perm>', 'Default permission: edit, read, or none')
416
- .option('--ai-access <perm>', 'AI access permission: edit, read, or none')
441
+ .option('--default-permission <perm>', 'Default permission for CREATED docs: edit, read, or none (an update leaves existing permissions alone)')
442
+ .option('--ai-access <perm>', 'AI access for CREATED docs: edit, read, or none (an update leaves existing permissions alone)')
417
443
  .option('--visibility <vis>', 'Convenience flag: team (read) or private (none)')
418
444
  .option('--created-after <date>', 'Only include docs created after this date (ISO 8601)')
419
445
  .option('--created-before <date>', 'Only include docs created before this date (ISO 8601)')
@@ -437,6 +463,7 @@ program
437
463
  console.error('Error: --on-conflict must be "skip" or "update"');
438
464
  process.exit(1);
439
465
  }
466
+ assertPermissionFlags(opts);
440
467
  // Resolve visibility → defaultPermission (visibility is syntactic sugar)
441
468
  let defaultPermission = opts.defaultPermission;
442
469
  if (!defaultPermission && opts.visibility) {
@@ -650,7 +677,7 @@ program
650
677
  // Cleanup temp files
651
678
  await source.cleanup();
652
679
  if (opts.json) {
653
- console.log(JSON.stringify(log, null, 2));
680
+ emitJson(log);
654
681
  }
655
682
  else {
656
683
  printSummary(log);
@@ -718,7 +745,7 @@ program
718
745
  dateFilter,
719
746
  });
720
747
  if (opts.json) {
721
- console.log(JSON.stringify(log, null, 2));
748
+ emitJson(log);
722
749
  }
723
750
  else {
724
751
  printNotionExportSummary(log);