@jordancoin/notioncli 1.1.0 → 1.2.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/README.md CHANGED
@@ -34,9 +34,21 @@ Zero UUIDs. One command. You're ready to go.
34
34
 
35
35
  ---
36
36
 
37
- ## What's New (v1.1)
37
+ ## What's New
38
38
 
39
- notioncli now understands Notion as a **graph**, not just a table.
39
+ ### v1.2 Multi-Workspace, Database Management & File Uploads
40
+
41
+ - 🏢 **Multi-workspace** — Named profiles for multiple Notion accounts (`workspace add/use/list/remove`, `--workspace` flag)
42
+ - 🤖 **`me`** — Show integration/bot identity and owner
43
+ - 📦 **`move`** — Move pages between databases by alias
44
+ - 📋 **`templates`** — List page templates available for a database
45
+ - 🏗️ **`db-create`** — Create new databases with custom property schemas
46
+ - ✏️ **`db-update`** — Add/remove columns, rename databases
47
+ - 📎 **`upload`** — Upload files to pages (MIME-aware, supports images/docs/text)
48
+ - 🔍 **`props`** — Quick page property inspector (cleaner than `get` for debugging)
49
+ - 🐛 **Fixed** canonical `database_id` resolution for the 2025 dual-ID system
50
+
51
+ ### v1.1 — Relations, Rollups & Blocks
40
52
 
41
53
  - 🔗 **Relations** — `get` resolves linked page titles automatically. New `notion relations` command for exploring connected pages.
42
54
  - 📊 **Rollups** — Numbers, dates, and arrays are parsed into readable values. No more raw JSON blobs.
@@ -267,6 +279,86 @@ $ notion comments tasks --filter "Name=Ship feature"
267
279
  $ notion comment tasks "Shipped! 🚀" --filter "Name=Ship feature"
268
280
  ```
269
281
 
282
+ ### `notion me` — Integration Identity
283
+
284
+ ```
285
+ $ notion me
286
+ Bot: Stargazer
287
+ ID: 8fd93059-5e54-44a5-8efd-800069da9497
288
+ Type: bot
289
+ Owner: Workspace
290
+ ```
291
+
292
+ ### `notion props` — Quick Property Inspector
293
+
294
+ A fast way to inspect a single page's properties:
295
+
296
+ ```
297
+ $ notion props tasks --filter "Name=Ship v1.1"
298
+ Page: a1b2c3d4-5678-90ab-cdef-1234567890ab
299
+ URL: https://www.notion.so/...
300
+ Name: Ship v1.1
301
+ Status: Done
302
+ Priority: High
303
+ Date: 2026-02-09
304
+ ```
305
+
306
+ ### `notion move` — Move Pages Between Databases
307
+
308
+ ```bash
309
+ $ notion move tasks --filter "Name=Archived task" --to archive
310
+ ✅ Moved page: a1b2c3d4…
311
+ URL: https://notion.so/...
312
+ ```
313
+
314
+ Accepts alias + filter for the source page, and an alias or page ID for `--to`.
315
+
316
+ ### `notion templates` — List Database Templates
317
+
318
+ ```bash
319
+ $ notion templates projects
320
+ id │ title │ url
321
+ ──────────┼────────────────────┼──────────────────
322
+ a1b2c3d4… │ Project Template │ https://notion.so/...
323
+ ```
324
+
325
+ ### `notion db-create` — Create a Database
326
+
327
+ ```bash
328
+ $ notion db-create <parent-page-id> "My New DB" --prop "Name:title" --prop "Status:select" --prop "Priority:number"
329
+ ✅ Created database: a1b2c3d4…
330
+ Title: My New DB
331
+ Properties: Name, Status, Priority
332
+ ```
333
+
334
+ ### `notion db-update` — Update Database Schema
335
+
336
+ Add or remove columns, rename databases:
337
+
338
+ ```bash
339
+ $ notion db-update projects --add-prop "Rating:number" --add-prop "Category:select"
340
+ ✅ Updated database: a1b2c3d4…
341
+ Added: Rating:number, Category:select
342
+
343
+ $ notion db-update projects --remove-prop "Old Column"
344
+ ✅ Updated database: a1b2c3d4…
345
+ Removed: Old Column
346
+
347
+ $ notion db-update projects --title "Renamed Projects"
348
+ ✅ Updated database: a1b2c3d4…
349
+ Title: Renamed Projects
350
+ ```
351
+
352
+ ### `notion upload` — Upload Files to Pages
353
+
354
+ ```bash
355
+ $ notion upload tasks --filter "Name=Ship feature" ./screenshot.png
356
+ ✅ Uploaded: screenshot.png (142.3 KB)
357
+ Page: a1b2c3d4…
358
+ ```
359
+
360
+ Supports images, PDFs, text files, documents, and more. MIME types are detected automatically from file extensions.
361
+
270
362
  ### `notion alias` — Manage Aliases
271
363
 
272
364
  Aliases are created automatically by `notion init`, but you can manage them:
@@ -278,6 +370,38 @@ notion alias add tasks <database-id> # Add manually
278
370
  notion alias remove tasks # Remove one
279
371
  ```
280
372
 
373
+ ### `notion workspace` — Multi-Workspace Profiles
374
+
375
+ Manage multiple Notion accounts (work, personal, client projects):
376
+
377
+ ```bash
378
+ # Add a workspace
379
+ notion workspace add work --key ntn_your_work_key
380
+ notion workspace add personal --key ntn_your_personal_key
381
+
382
+ # List workspaces
383
+ notion workspace list
384
+ # default ← active
385
+ # Key: ntn_3871... | Aliases: 5
386
+ # work
387
+ # Key: ntn_ab12... | Aliases: 0
388
+
389
+ # Switch active workspace
390
+ notion workspace use work
391
+
392
+ # Discover databases for a workspace
393
+ notion init --workspace work
394
+
395
+ # Per-command override (no switching needed)
396
+ notion query tasks --workspace personal
397
+ notion -w work add projects --prop "Name=Q2 Plan"
398
+
399
+ # Remove a workspace
400
+ notion workspace remove old-client
401
+ ```
402
+
403
+ Aliases are scoped per workspace — same alias name in different workspaces won't collide. Old single-key configs are auto-migrated to a "default" workspace.
404
+
281
405
  ### `--json` — Raw JSON Output
282
406
 
283
407
  Add `--json` to any command for the raw Notion API response:
package/bin/notion.js CHANGED
@@ -19,12 +19,35 @@ function saveConfig(config) {
19
19
  }
20
20
 
21
21
  /**
22
- * Resolve API key: env var config file error with setup instructions
22
+ * Get the active workspace name from --workspace flag or config.
23
+ */
24
+ function getWorkspaceName() {
25
+ return program.opts().workspace || undefined;
26
+ }
27
+
28
+ /**
29
+ * Get the active workspace config { apiKey, aliases, name }.
30
+ */
31
+ function getWorkspaceConfig() {
32
+ const config = loadConfig();
33
+ const ws = helpers.resolveWorkspace(config, getWorkspaceName());
34
+ if (ws.error) {
35
+ console.error(`Error: ${ws.error}`);
36
+ if (ws.available && ws.available.length > 0) {
37
+ console.error(`Available workspaces: ${ws.available.join(', ')}`);
38
+ }
39
+ process.exit(1);
40
+ }
41
+ return ws;
42
+ }
43
+
44
+ /**
45
+ * Resolve API key: env var → workspace config → error with setup instructions
23
46
  */
24
47
  function getApiKey() {
25
48
  if (process.env.NOTION_API_KEY) return process.env.NOTION_API_KEY;
26
- const config = loadConfig();
27
- if (config.apiKey) return config.apiKey;
49
+ const ws = getWorkspaceConfig();
50
+ if (ws.apiKey) return ws.apiKey;
28
51
  console.error('Error: No Notion API key found.');
29
52
  console.error('');
30
53
  console.error('Set it up with one of:');
@@ -40,14 +63,14 @@ function getApiKey() {
40
63
  * If given a raw UUID, we use it for both IDs (the SDK figures it out).
41
64
  */
42
65
  function resolveDb(aliasOrId) {
43
- const config = loadConfig();
44
- if (config.aliases && config.aliases[aliasOrId]) {
45
- return config.aliases[aliasOrId];
66
+ const ws = getWorkspaceConfig();
67
+ if (ws.aliases && ws.aliases[aliasOrId]) {
68
+ return ws.aliases[aliasOrId];
46
69
  }
47
70
  if (UUID_REGEX.test(aliasOrId)) {
48
71
  return { database_id: aliasOrId, data_source_id: aliasOrId };
49
72
  }
50
- const aliasNames = config.aliases ? Object.keys(config.aliases) : [];
73
+ const aliasNames = ws.aliases ? Object.keys(ws.aliases) : [];
51
74
  console.error(`Unknown database alias: "${aliasOrId}"`);
52
75
  if (aliasNames.length > 0) {
53
76
  console.error(`Available aliases: ${aliasNames.join(', ')}`);
@@ -64,14 +87,14 @@ function resolveDb(aliasOrId) {
64
87
  * Returns { pageId, dbIds } where dbIds is non-null when resolved via alias.
65
88
  */
66
89
  async function resolvePageId(aliasOrId, filterStr) {
67
- const config = loadConfig();
68
- if (config.aliases && config.aliases[aliasOrId]) {
90
+ const ws = getWorkspaceConfig();
91
+ if (ws.aliases && ws.aliases[aliasOrId]) {
69
92
  if (!filterStr) {
70
93
  console.error('When using an alias, --filter is required to identify a specific page.');
71
94
  console.error(`Example: notion update ${aliasOrId} --filter "Name=My Page" --prop "Status=Done"`);
72
95
  process.exit(1);
73
96
  }
74
- const dbIds = config.aliases[aliasOrId];
97
+ const dbIds = ws.aliases[aliasOrId];
75
98
  const notion = getNotion();
76
99
  const filter = await buildFilter(dbIds, filterStr);
77
100
  const res = await notion.dataSources.query({
@@ -94,7 +117,7 @@ async function resolvePageId(aliasOrId, filterStr) {
94
117
  }
95
118
  // Check if it looks like a UUID — if not, it's probably a typo'd alias
96
119
  if (!UUID_REGEX.test(aliasOrId)) {
97
- const aliasNames = config.aliases ? Object.keys(config.aliases) : [];
120
+ const aliasNames = ws.aliases ? Object.keys(ws.aliases) : [];
98
121
  console.error(`Unknown alias: "${aliasOrId}"`);
99
122
  if (aliasNames.length > 0) {
100
123
  console.error(`Available aliases: ${aliasNames.join(', ')}`);
@@ -203,8 +226,9 @@ async function buildFilter(dbIds, filterStr) {
203
226
  program
204
227
  .name('notion')
205
228
  .description('A powerful CLI for the Notion API — query databases, manage pages, and automate your workspace from the terminal.')
206
- .version('1.1.0')
207
- .option('--json', 'Output raw JSON instead of formatted tables');
229
+ .version('1.2.0')
230
+ .option('--json', 'Output raw JSON instead of formatted tables')
231
+ .option('-w, --workspace <name>', 'Use a specific workspace profile');
208
232
 
209
233
  // ─── init ──────────────────────────────────────────────────────────────────────
210
234
  program
@@ -213,6 +237,7 @@ program
213
237
  .option('--key <api-key>', 'Notion integration API key (starts with ntn_)')
214
238
  .action(async (opts) => {
215
239
  const config = loadConfig();
240
+ const wsName = getWorkspaceName() || config.activeWorkspace || 'default';
216
241
  const apiKey = opts.key || process.env.NOTION_API_KEY;
217
242
 
218
243
  if (!apiKey) {
@@ -225,12 +250,16 @@ program
225
250
  console.error(' 4. Share your databases with the integration');
226
251
  console.error('');
227
252
  console.error('Then run: notion init --key ntn_your_api_key');
253
+ console.error(' Or with workspace: notion init --workspace work --key ntn_your_api_key');
228
254
  process.exit(1);
229
255
  }
230
256
 
231
- config.apiKey = apiKey;
257
+ if (!config.workspaces) config.workspaces = {};
258
+ if (!config.workspaces[wsName]) config.workspaces[wsName] = { aliases: {} };
259
+ config.workspaces[wsName].apiKey = apiKey;
260
+ config.activeWorkspace = wsName;
232
261
  saveConfig(config);
233
- console.log(`✅ API key saved to ${CONFIG_PATH}`);
262
+ console.log(`✅ API key saved to workspace "${wsName}" in ${CONFIG_PATH}`);
234
263
  console.log('');
235
264
 
236
265
  // Discover databases
@@ -247,7 +276,7 @@ program
247
276
  return;
248
277
  }
249
278
 
250
- if (!config.aliases) config.aliases = {};
279
+ const aliases = config.workspaces[wsName].aliases || {};
251
280
 
252
281
  console.log(`Found ${res.results.length} database${res.results.length !== 1 ? 's' : ''}:\n`);
253
282
 
@@ -255,7 +284,7 @@ program
255
284
  for (const db of res.results) {
256
285
  const title = richTextToPlain(db.title) || '';
257
286
  const dsId = db.id;
258
- const dbId = db.database_id || dsId;
287
+ const dbId = (db.parent && db.parent.type === 'database_id' && db.parent.database_id) || db.database_id || dsId;
259
288
 
260
289
  // Auto-generate a slug from the title
261
290
  let slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 30);
@@ -264,12 +293,12 @@ program
264
293
  // Avoid collisions — append a number if needed
265
294
  let finalSlug = slug;
266
295
  let counter = 2;
267
- while (config.aliases[finalSlug] && config.aliases[finalSlug].data_source_id !== dsId) {
296
+ while (aliases[finalSlug] && aliases[finalSlug].data_source_id !== dsId) {
268
297
  finalSlug = `${slug}-${counter}`;
269
298
  counter++;
270
299
  }
271
300
 
272
- config.aliases[finalSlug] = {
301
+ aliases[finalSlug] = {
273
302
  database_id: dbId,
274
303
  data_source_id: dsId,
275
304
  };
@@ -278,9 +307,10 @@ program
278
307
  added.push(finalSlug);
279
308
  }
280
309
 
310
+ config.workspaces[wsName].aliases = aliases;
281
311
  saveConfig(config);
282
312
  console.log('');
283
- console.log(`${added.length} alias${added.length !== 1 ? 'es' : ''} saved automatically.`);
313
+ console.log(`${added.length} alias${added.length !== 1 ? 'es' : ''} saved to workspace "${wsName}".`);
284
314
  console.log('');
285
315
  console.log('Ready! Try:');
286
316
  if (added.length > 0) {
@@ -308,7 +338,9 @@ alias
308
338
  .description('Add a database alias (auto-discovers data_source_id)')
309
339
  .action(async (name, databaseId) => {
310
340
  const config = loadConfig();
311
- if (!config.aliases) config.aliases = {};
341
+ const wsName = getWorkspaceName() || config.activeWorkspace || 'default';
342
+ if (!config.workspaces[wsName]) config.workspaces[wsName] = { aliases: {} };
343
+ const aliases = config.workspaces[wsName].aliases || {};
312
344
 
313
345
  // Try to discover the data_source_id by searching for this database
314
346
  const notion = getNotion();
@@ -328,9 +360,9 @@ alias
328
360
 
329
361
  if (match) {
330
362
  dataSourceId = match.id;
331
- // The database_id might differ from data_source_id
332
- const dbId = match.database_id || databaseId;
333
- config.aliases[name] = {
363
+ // The database_id might differ from data_source_id — check parent
364
+ const dbId = (match.parent && match.parent.type === 'database_id' && match.parent.database_id) || match.database_id || databaseId;
365
+ aliases[name] = {
334
366
  database_id: dbId,
335
367
  data_source_id: dataSourceId,
336
368
  };
@@ -340,7 +372,7 @@ alias
340
372
  console.log(` data_source_id: ${dataSourceId}`);
341
373
  } else {
342
374
  // Couldn't find via search — use the ID for both
343
- config.aliases[name] = {
375
+ aliases[name] = {
344
376
  database_id: databaseId,
345
377
  data_source_id: databaseId,
346
378
  };
@@ -349,7 +381,7 @@ alias
349
381
  }
350
382
  } catch (err) {
351
383
  // Fallback: use same ID for both
352
- config.aliases[name] = {
384
+ aliases[name] = {
353
385
  database_id: databaseId,
354
386
  data_source_id: databaseId,
355
387
  };
@@ -357,6 +389,7 @@ alias
357
389
  console.log(` (Auto-discovery failed: ${err.message})`);
358
390
  }
359
391
 
392
+ config.workspaces[wsName].aliases = aliases;
360
393
  saveConfig(config);
361
394
  });
362
395
 
@@ -364,16 +397,17 @@ alias
364
397
  .command('list')
365
398
  .description('Show all configured database aliases')
366
399
  .action(() => {
367
- const config = loadConfig();
368
- const aliases = config.aliases || {};
400
+ const ws = getWorkspaceConfig();
401
+ const aliases = ws.aliases || {};
369
402
  const names = Object.keys(aliases);
370
403
 
371
404
  if (names.length === 0) {
372
- console.log('No aliases configured.');
405
+ console.log(`No aliases in workspace "${ws.name}".`);
373
406
  console.log('Add one with: notion alias add <name> <database-id>');
374
407
  return;
375
408
  }
376
409
 
410
+ console.log(`Workspace: ${ws.name}\n`);
377
411
  const rows = names.map(name => ({
378
412
  alias: name,
379
413
  database_id: aliases[name].database_id,
@@ -387,17 +421,20 @@ alias
387
421
  .description('Remove a database alias')
388
422
  .action((name) => {
389
423
  const config = loadConfig();
390
- if (!config.aliases || !config.aliases[name]) {
391
- console.error(`Alias "${name}" not found.`);
392
- const names = config.aliases ? Object.keys(config.aliases) : [];
424
+ const wsName = getWorkspaceName() || config.activeWorkspace || 'default';
425
+ const aliases = config.workspaces[wsName]?.aliases || {};
426
+ if (!aliases[name]) {
427
+ console.error(`Alias "${name}" not found in workspace "${wsName}".`);
428
+ const names = Object.keys(aliases);
393
429
  if (names.length > 0) {
394
430
  console.error(`Available: ${names.join(', ')}`);
395
431
  }
396
432
  process.exit(1);
397
433
  }
398
- delete config.aliases[name];
434
+ delete aliases[name];
435
+ config.workspaces[wsName].aliases = aliases;
399
436
  saveConfig(config);
400
- console.log(`✅ Removed alias "${name}"`);
437
+ console.log(`✅ Removed alias "${name}" from workspace "${wsName}"`);
401
438
  });
402
439
 
403
440
  alias
@@ -405,22 +442,104 @@ alias
405
442
  .description('Rename a database alias')
406
443
  .action((oldName, newName) => {
407
444
  const config = loadConfig();
408
- if (!config.aliases || !config.aliases[oldName]) {
409
- console.error(`Alias "${oldName}" not found.`);
410
- const names = config.aliases ? Object.keys(config.aliases) : [];
445
+ const wsName = getWorkspaceName() || config.activeWorkspace || 'default';
446
+ const aliases = config.workspaces[wsName]?.aliases || {};
447
+ if (!aliases[oldName]) {
448
+ console.error(`Alias "${oldName}" not found in workspace "${wsName}".`);
449
+ const names = Object.keys(aliases);
411
450
  if (names.length > 0) {
412
451
  console.error(`Available: ${names.join(', ')}`);
413
452
  }
414
453
  process.exit(1);
415
454
  }
416
- if (config.aliases[newName]) {
455
+ if (aliases[newName]) {
417
456
  console.error(`Alias "${newName}" already exists. Remove it first or pick a different name.`);
418
457
  process.exit(1);
419
458
  }
420
- config.aliases[newName] = config.aliases[oldName];
421
- delete config.aliases[oldName];
459
+ aliases[newName] = aliases[oldName];
460
+ delete aliases[oldName];
461
+ config.workspaces[wsName].aliases = aliases;
462
+ saveConfig(config);
463
+ console.log(`✅ Renamed "${oldName}" → "${newName}" in workspace "${wsName}"`);
464
+ });
465
+
466
+ // ─── workspace ─────────────────────────────────────────────────────────────────
467
+ const workspace = program
468
+ .command('workspace')
469
+ .description('Manage workspace profiles (multiple Notion accounts)');
470
+
471
+ workspace
472
+ .command('add <name>')
473
+ .description('Add a new workspace profile')
474
+ .requiredOption('--key <api-key>', 'Notion API key for this workspace')
475
+ .action(async (name, opts) => {
476
+ const config = loadConfig();
477
+ if (config.workspaces[name]) {
478
+ console.error(`Workspace "${name}" already exists. Use "notion init --workspace ${name} --key ..." to update it.`);
479
+ process.exit(1);
480
+ }
481
+ config.workspaces[name] = { apiKey: opts.key, aliases: {} };
482
+ saveConfig(config);
483
+ console.log(`✅ Added workspace "${name}"`);
484
+ console.log('');
485
+ console.log(`Discover databases: notion init --workspace ${name}`);
486
+ console.log(`Or set as active: notion workspace use ${name}`);
487
+ });
488
+
489
+ workspace
490
+ .command('list')
491
+ .description('List all workspace profiles')
492
+ .action(() => {
493
+ const config = loadConfig();
494
+ const names = Object.keys(config.workspaces || {});
495
+ if (names.length === 0) {
496
+ console.log('No workspaces configured. Run: notion init --key ntn_...');
497
+ return;
498
+ }
499
+ for (const name of names) {
500
+ const ws = config.workspaces[name];
501
+ const active = name === config.activeWorkspace ? ' ← active' : '';
502
+ const aliasCount = Object.keys(ws.aliases || {}).length;
503
+ const keyPreview = ws.apiKey ? `${ws.apiKey.slice(0, 8)}...` : '(no key)';
504
+ console.log(` ${name}${active}`);
505
+ console.log(` Key: ${keyPreview} | Aliases: ${aliasCount}`);
506
+ }
507
+ });
508
+
509
+ workspace
510
+ .command('use <name>')
511
+ .description('Set the active workspace')
512
+ .action((name) => {
513
+ const config = loadConfig();
514
+ if (!config.workspaces[name]) {
515
+ console.error(`Workspace "${name}" not found.`);
516
+ const names = Object.keys(config.workspaces || {});
517
+ if (names.length > 0) {
518
+ console.error(`Available: ${names.join(', ')}`);
519
+ }
520
+ process.exit(1);
521
+ }
522
+ config.activeWorkspace = name;
523
+ saveConfig(config);
524
+ console.log(`✅ Active workspace: ${name}`);
525
+ });
526
+
527
+ workspace
528
+ .command('remove <name>')
529
+ .description('Remove a workspace profile')
530
+ .action((name) => {
531
+ const config = loadConfig();
532
+ if (!config.workspaces[name]) {
533
+ console.error(`Workspace "${name}" not found.`);
534
+ process.exit(1);
535
+ }
536
+ if (name === config.activeWorkspace) {
537
+ console.error(`Cannot remove the active workspace. Switch first: notion workspace use <other>`);
538
+ process.exit(1);
539
+ }
540
+ delete config.workspaces[name];
422
541
  saveConfig(config);
423
- console.log(`✅ Renamed "${oldName}" "${newName}"`);
542
+ console.log(`✅ Removed workspace "${name}"`);
424
543
  });
425
544
 
426
545
  // ─── search ────────────────────────────────────────────────────────────────────
@@ -1040,5 +1159,417 @@ program
1040
1159
  }
1041
1160
  });
1042
1161
 
1162
+ // ─── me ────────────────────────────────────────────────────────────────────────
1163
+ program
1164
+ .command('me')
1165
+ .description('Show details about the current integration/bot')
1166
+ .action(async (opts, cmd) => {
1167
+ try {
1168
+ const notion = getNotion();
1169
+ const me = await notion.users.me({});
1170
+ if (getGlobalJson(cmd)) {
1171
+ console.log(JSON.stringify(me, null, 2));
1172
+ return;
1173
+ }
1174
+ console.log(`Bot: ${me.name || '(unnamed)'}`);
1175
+ console.log(`ID: ${me.id}`);
1176
+ console.log(`Type: ${me.type}`);
1177
+ if (me.bot?.owner) {
1178
+ const owner = me.bot.owner;
1179
+ console.log(`Owner: ${owner.type === 'workspace' ? 'Workspace' : owner.user?.name || owner.type}`);
1180
+ }
1181
+ if (me.avatar_url) {
1182
+ console.log(`Avatar: ${me.avatar_url}`);
1183
+ }
1184
+ } catch (err) {
1185
+ console.error('Me failed:', err.message);
1186
+ process.exit(1);
1187
+ }
1188
+ });
1189
+
1190
+ // ─── move ──────────────────────────────────────────────────────────────────────
1191
+ program
1192
+ .command('move <page-or-alias>')
1193
+ .description('Move a page to a new parent (page or database)')
1194
+ .option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
1195
+ .option('--to <parent-id-or-alias>', 'Destination parent (page ID, database alias, or database ID)')
1196
+ .action(async (target, opts, cmd) => {
1197
+ try {
1198
+ if (!opts.to) {
1199
+ console.error('--to is required. Specify a parent page ID or database alias.');
1200
+ process.exit(1);
1201
+ }
1202
+ const notion = getNotion();
1203
+ const { pageId } = await resolvePageId(target, opts.filter);
1204
+
1205
+ // Resolve --to target
1206
+ let parent;
1207
+ const ws = getWorkspaceConfig();
1208
+ if (ws.aliases && ws.aliases[opts.to]) {
1209
+ const db = ws.aliases[opts.to];
1210
+ // pages.move() requires data_source_id parent, not database_id
1211
+ parent = { type: 'data_source_id', data_source_id: db.data_source_id };
1212
+ } else if (UUID_REGEX.test(opts.to)) {
1213
+ // Assume page ID — user can also pass a database_id
1214
+ parent = { type: 'page_id', page_id: opts.to };
1215
+ } else {
1216
+ console.error(`Unknown destination: "${opts.to}". Use a page ID or database alias.`);
1217
+ const aliasNames = ws.aliases ? Object.keys(ws.aliases) : [];
1218
+ if (aliasNames.length > 0) {
1219
+ console.error(`Available aliases: ${aliasNames.join(', ')}`);
1220
+ }
1221
+ process.exit(1);
1222
+ }
1223
+
1224
+ const res = await notion.pages.move({ page_id: pageId, parent });
1225
+ if (getGlobalJson(cmd)) {
1226
+ console.log(JSON.stringify(res, null, 2));
1227
+ return;
1228
+ }
1229
+ console.log(`✅ Moved page: ${pageId.slice(0, 8)}…`);
1230
+ if (res.url) console.log(` URL: ${res.url}`);
1231
+ } catch (err) {
1232
+ console.error('Move failed:', err.message);
1233
+ process.exit(1);
1234
+ }
1235
+ });
1236
+
1237
+ // ─── templates ─────────────────────────────────────────────────────────────────
1238
+ program
1239
+ .command('templates <database>')
1240
+ .description('List page templates available for a database')
1241
+ .action(async (db, opts, cmd) => {
1242
+ try {
1243
+ const notion = getNotion();
1244
+ const dbIds = resolveDb(db);
1245
+ const res = await notion.dataSources.listTemplates({
1246
+ data_source_id: dbIds.data_source_id,
1247
+ });
1248
+ if (getGlobalJson(cmd)) {
1249
+ console.log(JSON.stringify(res, null, 2));
1250
+ return;
1251
+ }
1252
+ if (!res.results || res.results.length === 0) {
1253
+ console.log('No templates found for this database.');
1254
+ return;
1255
+ }
1256
+ const rows = res.results.map(t => {
1257
+ let title = '';
1258
+ if (t.properties) {
1259
+ for (const [, prop] of Object.entries(t.properties)) {
1260
+ if (prop.type === 'title') {
1261
+ title = propValue(prop);
1262
+ break;
1263
+ }
1264
+ }
1265
+ }
1266
+ return {
1267
+ id: t.id,
1268
+ title: title || '(untitled)',
1269
+ url: t.url || '',
1270
+ };
1271
+ });
1272
+ printTable(rows, ['id', 'title', 'url']);
1273
+ } catch (err) {
1274
+ console.error('Templates failed:', err.message);
1275
+ process.exit(1);
1276
+ }
1277
+ });
1278
+
1279
+ // ─── db-create ─────────────────────────────────────────────────────────────────
1280
+ program
1281
+ .command('db-create <parent-page-id> <title>')
1282
+ .description('Create a new database under a page')
1283
+ .option('--prop <name:type...>', 'Property definition — repeatable (e.g. --prop "Status:select" --prop "Priority:number")', (v, prev) => prev.concat([v]), [])
1284
+ .option('--alias <name>', 'Auto-create an alias for the new database')
1285
+ .action(async (parentPageId, title, opts, cmd) => {
1286
+ try {
1287
+ const notion = getNotion();
1288
+
1289
+ // Build properties — always include a title property
1290
+ const properties = {};
1291
+ let hasTitleProp = false;
1292
+
1293
+ for (const kv of opts.prop) {
1294
+ const colonIdx = kv.indexOf(':');
1295
+ if (colonIdx === -1) {
1296
+ console.error(`Invalid property format: ${kv} (expected name:type)`);
1297
+ console.error('Supported types: title, rich_text, number, select, multi_select, date, checkbox, url, email, phone_number, status');
1298
+ process.exit(1);
1299
+ }
1300
+ const name = kv.slice(0, colonIdx);
1301
+ const type = kv.slice(colonIdx + 1).toLowerCase();
1302
+ if (type === 'title') hasTitleProp = true;
1303
+ properties[name] = { [type]: {} };
1304
+ }
1305
+
1306
+ // Ensure there's a title property
1307
+ if (!hasTitleProp) {
1308
+ properties['Name'] = { title: {} };
1309
+ }
1310
+
1311
+ const res = await notion.databases.create({
1312
+ parent: { type: 'page_id', page_id: parentPageId },
1313
+ title: [{ text: { content: title } }],
1314
+ properties,
1315
+ });
1316
+
1317
+ if (getGlobalJson(cmd)) {
1318
+ console.log(JSON.stringify(res, null, 2));
1319
+ return;
1320
+ }
1321
+
1322
+ console.log(`✅ Created database: ${res.id.slice(0, 8)}…`);
1323
+ console.log(` Title: ${title}`);
1324
+ console.log(` Properties: ${Object.keys(properties).join(', ')}`);
1325
+
1326
+ // Auto-create alias if requested
1327
+ if (opts.alias) {
1328
+ const config = loadConfig();
1329
+ const wsName = getWorkspaceName() || config.activeWorkspace || 'default';
1330
+ if (!config.workspaces[wsName]) config.workspaces[wsName] = { aliases: {} };
1331
+ if (!config.workspaces[wsName].aliases) config.workspaces[wsName].aliases = {};
1332
+ config.workspaces[wsName].aliases[opts.alias] = {
1333
+ database_id: res.database_id || res.id,
1334
+ data_source_id: res.id,
1335
+ };
1336
+ saveConfig(config);
1337
+ console.log(` Alias: ${opts.alias}`);
1338
+ }
1339
+ } catch (err) {
1340
+ console.error('Database create failed:', err.message);
1341
+ process.exit(1);
1342
+ }
1343
+ });
1344
+
1345
+ // ─── db-update ─────────────────────────────────────────────────────────────────
1346
+ program
1347
+ .command('db-update <database>')
1348
+ .description('Update a database title or add properties')
1349
+ .option('--title <text>', 'New database title')
1350
+ .option('--add-prop <name:type...>', 'Add a property (e.g. --add-prop "Priority:number")', (v, prev) => prev.concat([v]), [])
1351
+ .option('--remove-prop <name...>', 'Remove a property by name', (v, prev) => prev.concat([v]), [])
1352
+ .action(async (db, opts, cmd) => {
1353
+ try {
1354
+ const notion = getNotion();
1355
+ const dbIds = resolveDb(db);
1356
+
1357
+ // databases.update() requires the canonical database_id, which may differ
1358
+ // from data_source_id. Resolve via dataSources.retrieve().parent.database_id.
1359
+ let canonicalId = dbIds.database_id;
1360
+ if (canonicalId === dbIds.data_source_id) {
1361
+ try {
1362
+ const ds = await notion.dataSources.retrieve({ data_source_id: canonicalId });
1363
+ if (ds.parent && ds.parent.type === 'database_id') {
1364
+ canonicalId = ds.parent.database_id;
1365
+ }
1366
+ } catch (_) { /* fall through with what we have */ }
1367
+ }
1368
+
1369
+ const params = { database_id: canonicalId };
1370
+
1371
+ if (opts.title) {
1372
+ params.title = [{ text: { content: opts.title } }];
1373
+ }
1374
+
1375
+ if (opts.addProp.length > 0 || opts.removeProp.length > 0) {
1376
+ params.properties = {};
1377
+
1378
+ for (const kv of opts.addProp) {
1379
+ const colonIdx = kv.indexOf(':');
1380
+ if (colonIdx === -1) {
1381
+ console.error(`Invalid property format: ${kv} (expected name:type)`);
1382
+ process.exit(1);
1383
+ }
1384
+ const name = kv.slice(0, colonIdx);
1385
+ const type = kv.slice(colonIdx + 1).toLowerCase();
1386
+ params.properties[name] = { [type]: {} };
1387
+ }
1388
+
1389
+ for (const name of opts.removeProp) {
1390
+ params.properties[name] = null;
1391
+ }
1392
+ }
1393
+
1394
+ const res = await notion.databases.update(params);
1395
+
1396
+ if (getGlobalJson(cmd)) {
1397
+ console.log(JSON.stringify(res, null, 2));
1398
+ return;
1399
+ }
1400
+
1401
+ console.log(`✅ Updated database: ${(dbIds.database_id || dbIds.data_source_id).slice(0, 8)}…`);
1402
+ if (opts.title) console.log(` Title: ${opts.title}`);
1403
+ if (opts.addProp.length > 0) console.log(` Added: ${opts.addProp.join(', ')}`);
1404
+ if (opts.removeProp.length > 0) console.log(` Removed: ${opts.removeProp.join(', ')}`);
1405
+ } catch (err) {
1406
+ console.error('Database update failed:', err.message);
1407
+ process.exit(1);
1408
+ }
1409
+ });
1410
+
1411
+ // ─── upload ────────────────────────────────────────────────────────────────────
1412
+ program
1413
+ .command('upload <page-or-alias> <file-path>')
1414
+ .description('Upload a file to a page')
1415
+ .option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
1416
+ .action(async (target, filePath, opts, cmd) => {
1417
+ try {
1418
+ const notion = getNotion();
1419
+ const { pageId } = await resolvePageId(target, opts.filter);
1420
+
1421
+ // Resolve file path
1422
+ const absPath = path.resolve(filePath);
1423
+ if (!fs.existsSync(absPath)) {
1424
+ console.error(`File not found: ${absPath}`);
1425
+ process.exit(1);
1426
+ }
1427
+
1428
+ const filename = path.basename(absPath);
1429
+ const fileData = fs.readFileSync(absPath);
1430
+ const fileSize = fileData.length;
1431
+
1432
+ // Detect MIME type from extension
1433
+ const MIME_MAP = {
1434
+ '.txt': 'text/plain', '.csv': 'text/csv', '.html': 'text/html',
1435
+ '.json': 'application/json', '.pdf': 'application/pdf',
1436
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
1437
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
1438
+ '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
1439
+ '.zip': 'application/zip', '.doc': 'application/msword',
1440
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1441
+ '.xls': 'application/vnd.ms-excel',
1442
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1443
+ };
1444
+ const ext = path.extname(filename).toLowerCase();
1445
+ const mimeType = MIME_MAP[ext] || 'application/octet-stream';
1446
+
1447
+ // Step 1: Create file upload
1448
+ const upload = await notion.fileUploads.create({
1449
+ parent: { type: 'page_id', page_id: pageId },
1450
+ filename,
1451
+ });
1452
+ const uploadId = upload.id;
1453
+
1454
+ // Step 2: Send file data with correct content type
1455
+ await notion.fileUploads.send({
1456
+ file_upload_id: uploadId,
1457
+ file: { data: new Blob([fileData], { type: mimeType }), filename },
1458
+ part_number: '1',
1459
+ });
1460
+
1461
+ // Step 3: Append file block to page (no complete() needed — attach directly)
1462
+ await notion.blocks.children.append({
1463
+ block_id: pageId,
1464
+ children: [{
1465
+ object: 'block',
1466
+ type: 'file',
1467
+ file: {
1468
+ type: 'file_upload',
1469
+ file_upload: { id: uploadId },
1470
+ },
1471
+ }],
1472
+ });
1473
+
1474
+ if (getGlobalJson(cmd)) {
1475
+ console.log(JSON.stringify({ upload_id: uploadId, filename, size: fileSize, page_id: pageId }, null, 2));
1476
+ return;
1477
+ }
1478
+
1479
+ const sizeStr = fileSize > 1024 * 1024
1480
+ ? `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
1481
+ : `${(fileSize / 1024).toFixed(1)} KB`;
1482
+
1483
+ console.log(`✅ Uploaded: ${filename} (${sizeStr})`);
1484
+ console.log(` Page: ${pageId.slice(0, 8)}…`);
1485
+ } catch (err) {
1486
+ console.error('Upload failed:', err.message);
1487
+ process.exit(1);
1488
+ }
1489
+ });
1490
+
1491
+ // ─── props ─────────────────────────────────────────────────────────────────────
1492
+ program
1493
+ .command('props <page-or-alias>')
1494
+ .description('List all properties with full paginated values')
1495
+ .option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
1496
+ .action(async (target, opts, cmd) => {
1497
+ try {
1498
+ const notion = getNotion();
1499
+ const { pageId } = await resolvePageId(target, opts.filter);
1500
+ const page = await notion.pages.retrieve({ page_id: pageId });
1501
+
1502
+ if (getGlobalJson(cmd)) {
1503
+ console.log(JSON.stringify(page, null, 2));
1504
+ return;
1505
+ }
1506
+
1507
+ console.log(`Page: ${page.id}`);
1508
+ console.log(`URL: ${page.url}\n`);
1509
+
1510
+ for (const [name, prop] of Object.entries(page.properties)) {
1511
+ // For paginated properties (relation, rollup, rich_text, title, people),
1512
+ // use the property retrieval endpoint to get full values
1513
+ const needsPagination = ['relation', 'rollup', 'rich_text', 'title', 'people'].includes(prop.type);
1514
+
1515
+ if (needsPagination && prop.id) {
1516
+ try {
1517
+ const fullProp = await notion.pages.properties.retrieve({
1518
+ page_id: pageId,
1519
+ property_id: prop.id,
1520
+ });
1521
+
1522
+ if (fullProp.results) {
1523
+ // Paginated property — collect all results
1524
+ const items = fullProp.results;
1525
+ if (prop.type === 'relation') {
1526
+ if (items.length === 0) {
1527
+ console.log(` ${name}: (none)`);
1528
+ } else {
1529
+ const titles = [];
1530
+ for (const item of items) {
1531
+ const relId = item.relation?.id;
1532
+ if (relId) {
1533
+ try {
1534
+ const linked = await notion.pages.retrieve({ page_id: relId });
1535
+ let t = '';
1536
+ for (const [, p] of Object.entries(linked.properties)) {
1537
+ if (p.type === 'title') { t = propValue(p); break; }
1538
+ }
1539
+ titles.push(t || relId.slice(0, 8) + '…');
1540
+ } catch {
1541
+ titles.push(relId.slice(0, 8) + '…');
1542
+ }
1543
+ }
1544
+ }
1545
+ console.log(` ${name}: ${titles.join(', ')}`);
1546
+ }
1547
+ } else if (prop.type === 'rich_text' || prop.type === 'title') {
1548
+ const text = items.map(i => i[prop.type]?.plain_text || '').join('');
1549
+ console.log(` ${name}: ${text}`);
1550
+ } else if (prop.type === 'people') {
1551
+ const people = items.map(i => i.people?.name || i.people?.id || '').join(', ');
1552
+ console.log(` ${name}: ${people}`);
1553
+ } else {
1554
+ console.log(` ${name}: ${JSON.stringify(items)}`);
1555
+ }
1556
+ } else {
1557
+ // Non-paginated response
1558
+ console.log(` ${name}: ${propValue(fullProp)}`);
1559
+ }
1560
+ } catch {
1561
+ // Fallback to basic propValue
1562
+ console.log(` ${name}: ${propValue(prop)}`);
1563
+ }
1564
+ } else {
1565
+ console.log(` ${name}: ${propValue(prop)}`);
1566
+ }
1567
+ }
1568
+ } catch (err) {
1569
+ console.error('Props failed:', err.message);
1570
+ process.exit(1);
1571
+ }
1572
+ });
1573
+
1043
1574
  // ─── Run ───────────────────────────────────────────────────────────────────────
1044
1575
  program.parse();
package/lib/helpers.js CHANGED
@@ -17,14 +17,43 @@ function getConfigPaths(overrideDir) {
17
17
  }
18
18
 
19
19
  function loadConfig(configPath) {
20
+ let config;
20
21
  try {
21
22
  if (fs.existsSync(configPath)) {
22
- return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
23
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
23
24
  }
24
25
  } catch (err) {
25
26
  // Corrupted config — start fresh
26
27
  }
27
- return { aliases: {} };
28
+ if (!config) config = { activeWorkspace: 'default', workspaces: { default: { aliases: {} } } };
29
+ return migrateConfig(config);
30
+ }
31
+
32
+ /**
33
+ * Migrate old flat config → multi-workspace format.
34
+ * Old: { apiKey, aliases }
35
+ * New: { activeWorkspace, workspaces: { name: { apiKey, aliases } } }
36
+ */
37
+ function migrateConfig(config) {
38
+ if (config.workspaces) return config; // already migrated
39
+ // Old format detected — wrap in "default" workspace
40
+ const ws = { aliases: config.aliases || {} };
41
+ if (config.apiKey) ws.apiKey = config.apiKey;
42
+ return { activeWorkspace: 'default', workspaces: { default: ws } };
43
+ }
44
+
45
+ /**
46
+ * Get the config for a specific workspace (or the active one).
47
+ * Returns { apiKey, aliases } for that workspace.
48
+ */
49
+ function resolveWorkspace(config, workspaceName) {
50
+ const name = workspaceName || config.activeWorkspace || 'default';
51
+ const ws = config.workspaces && config.workspaces[name];
52
+ if (!ws) {
53
+ const available = config.workspaces ? Object.keys(config.workspaces) : [];
54
+ return { error: `Unknown workspace: "${name}"`, available, name };
55
+ }
56
+ return { apiKey: ws.apiKey, aliases: ws.aliases || {}, name };
28
57
  }
29
58
 
30
59
  function saveConfig(config, configDir, configPath) {
@@ -292,6 +321,8 @@ function buildFilterFromSchema(schema, filterStr) {
292
321
  module.exports = {
293
322
  getConfigPaths,
294
323
  loadConfig,
324
+ migrateConfig,
325
+ resolveWorkspace,
295
326
  saveConfig,
296
327
  richTextToPlain,
297
328
  propValue,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jordancoin/notioncli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A powerful CLI for the Notion API — query databases, manage pages, and automate your workspace from the terminal.",
5
5
  "main": "bin/notion.js",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: notion
3
- description: Notion API for creating and managing pages, databases, blocks, relations, and rollups via the notioncli CLI tool.
3
+ description: Notion API for creating and managing pages, databases, blocks, relations, rollups, and multi-workspace profiles via the notioncli CLI tool.
4
4
  homepage: https://github.com/JordanCoin/notioncli
5
5
  metadata:
6
6
  openclaw:
@@ -166,6 +166,39 @@ notion comment <page-id> "Looks good, shipping this!" # By page ID
166
166
  notion comment tasks "AI review complete ✅" --filter "Name=Ship feature" # By alias + filter
167
167
  ```
168
168
 
169
+ ### Page Inspector
170
+
171
+ ```bash
172
+ notion props tasks --filter "Name=Ship feature" # Quick property dump
173
+ notion me # Show bot identity and owner
174
+ ```
175
+
176
+ ### Database Management
177
+
178
+ ```bash
179
+ notion db-create <parent-page-id> "New DB" --prop "Name:title" --prop "Status:select"
180
+ notion db-update tasks --add-prop "Rating:number" # Add a column
181
+ notion db-update tasks --remove-prop "Old Column" # Remove a column
182
+ notion db-update tasks --title "Renamed DB" # Rename database
183
+ notion templates tasks # List page templates
184
+ ```
185
+
186
+ ### Moving Pages
187
+
188
+ ```bash
189
+ notion move tasks --filter "Name=Done task" --to archive # Move by alias
190
+ notion move tasks --filter "Name=Done task" --to <page-id> # Move to page
191
+ ```
192
+
193
+ ### File Uploads
194
+
195
+ ```bash
196
+ notion upload tasks --filter "Name=Ship feature" ./report.pdf
197
+ notion upload <page-id> ./screenshot.png
198
+ ```
199
+
200
+ Supports images, PDFs, text files, documents. MIME types auto-detected from extension.
201
+
169
202
  ### Search
170
203
 
171
204
  ```bash
@@ -253,6 +286,29 @@ notion delete tasks --filter "Name=Old task"
253
286
  notion delete workouts --filter "Date=2026-02-09"
254
287
  ```
255
288
 
289
+ ### 9. Manage database schema
290
+
291
+ ```bash
292
+ notion db-update tasks --add-prop "Priority:select" # Add column
293
+ notion db-update tasks --remove-prop "Old Field" # Remove column
294
+ notion db-create <parent-page-id> "New DB" --prop "Name:title" --prop "Status:select"
295
+ ```
296
+
297
+ ### 10. Move pages and upload files
298
+
299
+ ```bash
300
+ notion move tasks --filter "Name=Done" --to archive
301
+ notion upload tasks --filter "Name=Ship feature" ./report.pdf
302
+ ```
303
+
304
+ ### 11. Inspect and debug
305
+
306
+ ```bash
307
+ notion me # Check integration identity
308
+ notion props tasks --filter "Name=Ship feature" # Quick property dump
309
+ notion templates tasks # List available templates
310
+ ```
311
+
256
312
  ## Property Type Reference
257
313
 
258
314
  When using `--prop key=value`, the CLI auto-detects the property type from the database schema:
@@ -271,6 +327,27 @@ When using `--prop key=value`, the CLI auto-detects the property type from the d
271
327
  | `phone_number` | `Phone=+1234567890` | Phone number string |
272
328
  | `status` | `Status=In Progress` | Status property |
273
329
 
330
+ ## Multi-Workspace Profiles
331
+
332
+ Manage multiple Notion accounts from one CLI:
333
+
334
+ ```bash
335
+ notion workspace add work --key ntn_work_key # Add workspace
336
+ notion workspace add personal --key ntn_personal # Add another
337
+ notion workspace list # Show all
338
+ notion workspace use work # Switch active
339
+ notion workspace remove old # Remove one
340
+
341
+ # Per-command override
342
+ notion query tasks --workspace personal
343
+ notion -w work add projects --prop "Name=Q2 Plan"
344
+
345
+ # Init with workspace
346
+ notion init --workspace work --key ntn_work_key
347
+ ```
348
+
349
+ Aliases are scoped per workspace. Old single-key configs auto-migrate to a "default" workspace.
350
+
274
351
  ## Notion API 2025 — Dual IDs
275
352
 
276
353
  The Notion API (2025-09-03) uses dual IDs for databases: a `database_id` and a `data_source_id`. notioncli handles this automatically — when you run `notion init` or `notion alias add`, both IDs are discovered and stored. You never need to think about it.
package/test/mock.test.js CHANGED
@@ -34,44 +34,61 @@ describe('Config management', () => {
34
34
 
35
35
  it('loadConfig returns empty config for missing file', () => {
36
36
  const config = loadConfig(configPath);
37
- assert.deepEqual(config, { aliases: {} });
37
+ assert.equal(config.activeWorkspace, 'default');
38
+ assert.ok(config.workspaces.default);
39
+ assert.deepEqual(config.workspaces.default.aliases, {});
38
40
  });
39
41
 
40
42
  it('loadConfig returns empty config for corrupted file', () => {
41
43
  fs.mkdirSync(configDir, { recursive: true });
42
44
  fs.writeFileSync(configPath, 'not json at all!!!');
43
45
  const config = loadConfig(configPath);
44
- assert.deepEqual(config, { aliases: {} });
46
+ assert.equal(config.activeWorkspace, 'default');
47
+ assert.ok(config.workspaces.default);
45
48
  });
46
49
 
47
50
  it('saveConfig creates directory and file', () => {
48
- const config = { aliases: { test: { database_id: 'db1', data_source_id: 'ds1' } } };
51
+ const config = { activeWorkspace: 'default', workspaces: { default: { aliases: { test: { database_id: 'db1', data_source_id: 'ds1' } } } } };
49
52
  saveConfig(config, configDir, configPath);
50
53
  assert.ok(fs.existsSync(configPath));
51
54
  });
52
55
 
53
56
  it('loadConfig reads back saved config', () => {
54
57
  const config = {
55
- apiKey: 'ntn_test_key',
56
- aliases: {
57
- projects: { database_id: 'db-123', data_source_id: 'ds-456' },
58
+ activeWorkspace: 'default',
59
+ workspaces: {
60
+ default: {
61
+ apiKey: 'ntn_test_key',
62
+ aliases: {
63
+ projects: { database_id: 'db-123', data_source_id: 'ds-456' },
64
+ },
65
+ },
58
66
  },
59
67
  };
60
68
  saveConfig(config, configDir, configPath);
61
69
  const loaded = loadConfig(configPath);
62
- assert.equal(loaded.apiKey, 'ntn_test_key');
63
- assert.deepEqual(loaded.aliases.projects, {
70
+ assert.equal(loaded.workspaces.default.apiKey, 'ntn_test_key');
71
+ assert.deepEqual(loaded.workspaces.default.aliases.projects, {
64
72
  database_id: 'db-123',
65
73
  data_source_id: 'ds-456',
66
74
  });
67
75
  });
68
76
 
69
77
  it('saveConfig overwrites existing config', () => {
70
- saveConfig({ aliases: { a: { database_id: '1', data_source_id: '1' } } }, configDir, configPath);
71
- saveConfig({ aliases: { b: { database_id: '2', data_source_id: '2' } } }, configDir, configPath);
78
+ saveConfig({ activeWorkspace: 'default', workspaces: { default: { aliases: { a: { database_id: '1', data_source_id: '1' } } } } }, configDir, configPath);
79
+ saveConfig({ activeWorkspace: 'default', workspaces: { default: { aliases: { b: { database_id: '2', data_source_id: '2' } } } } }, configDir, configPath);
72
80
  const loaded = loadConfig(configPath);
73
- assert.ok(!loaded.aliases.a);
74
- assert.ok(loaded.aliases.b);
81
+ assert.ok(!loaded.workspaces.default.aliases.a);
82
+ assert.ok(loaded.workspaces.default.aliases.b);
83
+ });
84
+
85
+ it('loadConfig auto-migrates old flat format', () => {
86
+ const oldConfig = { apiKey: 'ntn_old', aliases: { tasks: { database_id: 'db1', data_source_id: 'ds1' } } };
87
+ saveConfig(oldConfig, configDir, configPath);
88
+ const loaded = loadConfig(configPath);
89
+ assert.equal(loaded.activeWorkspace, 'default');
90
+ assert.equal(loaded.workspaces.default.apiKey, 'ntn_old');
91
+ assert.deepEqual(loaded.workspaces.default.aliases.tasks, { database_id: 'db1', data_source_id: 'ds1' });
75
92
  });
76
93
  });
77
94
 
@@ -108,14 +125,18 @@ describe('resolveDb logic (pure)', () => {
108
125
 
109
126
  it('known alias resolves to database_id + data_source_id', () => {
110
127
  const config = {
111
- aliases: {
112
- projects: { database_id: 'db-aaa', data_source_id: 'ds-bbb' },
128
+ activeWorkspace: 'default',
129
+ workspaces: {
130
+ default: {
131
+ aliases: {
132
+ projects: { database_id: 'db-aaa', data_source_id: 'ds-bbb' },
133
+ },
134
+ },
113
135
  },
114
136
  };
115
137
  saveConfig(config, configDir, configPath);
116
138
  const loaded = loadConfig(configPath);
117
- const alias = 'projects';
118
- const result = loaded.aliases[alias];
139
+ const result = loaded.workspaces.default.aliases.projects;
119
140
  assert.ok(result);
120
141
  assert.equal(result.database_id, 'db-aaa');
121
142
  assert.equal(result.data_source_id, 'ds-bbb');
@@ -132,9 +153,9 @@ describe('resolveDb logic (pure)', () => {
132
153
  });
133
154
 
134
155
  it('config with no aliases returns empty aliases object', () => {
135
- saveConfig({ aliases: {} }, configDir, configPath);
156
+ saveConfig({ activeWorkspace: 'default', workspaces: { default: { aliases: {} } } }, configDir, configPath);
136
157
  const loaded = loadConfig(configPath);
137
- assert.deepEqual(Object.keys(loaded.aliases), []);
158
+ assert.deepEqual(Object.keys(loaded.workspaces.default.aliases), []);
138
159
  });
139
160
  });
140
161