@eightstate/escli 0.7.1 → 0.8.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.
@@ -0,0 +1,23 @@
1
+ import { BaseCommand } from '../../base-command.js';
2
+ import { buildUsageError } from '../../lib/envelope.js';
3
+ import { ErrorCode } from '@eightstate/contracts/errors';
4
+ export default class McpTopic extends BaseCommand {
5
+ static errors = [ErrorCode.UsageInvalid];
6
+ static summary = 'Expose a local directory to ChatGPT via a remote MCP server';
7
+ static description = 'Register, serve, inspect, and revoke a local MCP machine exposed through a Cloudflare tunnel.';
8
+ static examples = [
9
+ '<%= config.bin %> mcp register --root . --label "work repo"',
10
+ '<%= config.bin %> mcp serve --root .',
11
+ '<%= config.bin %> mcp status',
12
+ '<%= config.bin %> mcp revoke --slug my-repo',
13
+ ];
14
+ static aliases = [];
15
+ static flags = {};
16
+ static args = {};
17
+ static enableJsonFlag = true;
18
+ static strict = true;
19
+ async execute() {
20
+ throw buildUsageError('usage: escli mcp <register|serve|status|revoke>');
21
+ }
22
+ }
23
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,91 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { z } from 'zod';
3
+ import { ErrorCode } from '@eightstate/contracts/errors';
4
+ import { BaseCommand } from '../../base-command.js';
5
+ import { writeStderr } from '../../io/io.js';
6
+ import { resolveCliToken } from '../../services/credentials.js';
7
+ import { authRequired, DEFAULT_MCP_DOMAIN, maskSecret, usageError } from '../../services/mcp/common.js';
8
+ import { CloudflareClient } from '../../services/mcp/cloudflare.js';
9
+ import { validateRootDirectory, writeMachineConfig } from '../../services/mcp/config.js';
10
+ import { McpGateClient } from '../../services/mcp/gate-client.js';
11
+ export const McpRegisterDataSchema = z.object({
12
+ slug: z.string(),
13
+ label: z.string().optional(),
14
+ root_default: z.string(),
15
+ hostname: z.string(),
16
+ resource: z.string(),
17
+ port: z.number(),
18
+ machine_id: z.number(),
19
+ tunnel_id: z.string(),
20
+ dns_record_id: z.string(),
21
+ config_path: z.string(),
22
+ connector_url: z.string(),
23
+ });
24
+ export default class McpRegister extends BaseCommand {
25
+ static errors = [ErrorCode.AuthRequired, ErrorCode.UsageInvalid, ErrorCode.ConfigInvalid, ErrorCode.ApiError, ErrorCode.GateUnavailable, ErrorCode.GateInvalidResponse, ErrorCode.NetworkError];
26
+ static summary = 'Register a local directory as an MCP machine';
27
+ static examples = ['<%= config.bin %> mcp register --root . --label "repo"', '<%= config.bin %> mcp register --root . --slug my-repo'];
28
+ static enableJsonFlag = true;
29
+ static strict = true;
30
+ static flags = {
31
+ root: Flags.string({ description: 'Root directory to expose.', required: true }),
32
+ slug: Flags.string({ description: 'Machine slug. Defaults from --label, root basename, or hostname.' }),
33
+ port: Flags.integer({ description: 'Local MCP origin port.', default: 9315 }),
34
+ label: Flags.string({ description: 'Human label for this machine.' }),
35
+ 'cf-account-id': Flags.string({ description: 'Cloudflare account ID. Defaults to CLOUDFLARE_ACCOUNT_ID.' }),
36
+ 'cf-zone-id': Flags.string({ description: 'Cloudflare zone ID. Defaults to CLOUDFLARE_ZONE_ID.' }),
37
+ 'cf-api-token': Flags.string({ description: 'Cloudflare API token. Defaults to CLOUDFLARE_API_TOKEN; never persisted.' }),
38
+ };
39
+ async execute() {
40
+ const flags = await this.parseFlags(McpRegister);
41
+ if (!resolveCliToken())
42
+ throw authRequired();
43
+ const root = await validateRootDirectory(flags.root);
44
+ const port = flags.port;
45
+ if (port < 1 || port > 65535)
46
+ throw usageError('--port must be between 1 and 65535');
47
+ const slug = normalizeSlug(flags.slug ?? flags.label ?? root.split('/').filter(Boolean).pop() ?? 'machine');
48
+ const hostname = `mcp-${slug}.${DEFAULT_MCP_DOMAIN}`;
49
+ const cf = new CloudflareClient({ accountId: flags['cf-account-id'], zoneId: flags['cf-zone-id'], apiToken: flags['cf-api-token'] });
50
+ let tunnelId;
51
+ let dnsRecordId;
52
+ try {
53
+ const tunnel = await cf.createTunnel(slug);
54
+ tunnelId = tunnel.id;
55
+ await cf.configureIngress(tunnel.id, hostname, port);
56
+ const dns = await cf.createDnsRecord(hostname, tunnel.id);
57
+ dnsRecordId = dns.id;
58
+ const gate = await new McpGateClient().createMachine({ slug, hostname, tunnel_id: tunnel.id, port, label: flags.label });
59
+ const config = {
60
+ slug,
61
+ label: flags.label,
62
+ root_default: root,
63
+ hostname: gate.hostname,
64
+ resource: gate.resource,
65
+ tunnel_id: tunnel.id,
66
+ tunnel_token: tunnel.token,
67
+ dns_record_id: dns.id,
68
+ port,
69
+ machine_id: gate.machine_id,
70
+ machine_secret: gate.machine_secret,
71
+ created_at: new Date().toISOString(),
72
+ };
73
+ const configPath = await writeMachineConfig(config);
74
+ if (!flags.quiet && !flags.json)
75
+ await writeStderr(`connector URL: ${gate.resource}/mcp\nconfig: ${configPath}\ntunnel: ${maskSecret(tunnel.id)}\n`);
76
+ return { slug, label: flags.label, root_default: root, hostname: gate.hostname, resource: gate.resource, port, machine_id: gate.machine_id, tunnel_id: tunnel.id, dns_record_id: dns.id, config_path: configPath, connector_url: `${gate.resource}/mcp` };
77
+ }
78
+ catch (error) {
79
+ const cleanup = [`Cloudflare tunnel id: ${tunnelId ?? '(not created)'}`, `Cloudflare DNS record id: ${dnsRecordId ?? '(not created)'}`].join('\n');
80
+ await writeStderr(`MCP register failed. Cleanup references:\n${cleanup}\n`);
81
+ throw error;
82
+ }
83
+ }
84
+ }
85
+ function normalizeSlug(value) {
86
+ const slug = value.toLowerCase().trim().replace(/[^a-z0-9]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 63) || 'machine';
87
+ if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/u.test(slug))
88
+ throw usageError('could not derive a valid slug; pass --slug');
89
+ return slug;
90
+ }
91
+ //# sourceMappingURL=register.js.map
@@ -0,0 +1,40 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { z } from 'zod';
3
+ import { ErrorCode } from '@eightstate/contracts/errors';
4
+ import { BaseCommand } from '../../base-command.js';
5
+ import { CloudflareClient } from '../../services/mcp/cloudflare.js';
6
+ import { deleteMachineConfig, resolveMachineConfig } from '../../services/mcp/config.js';
7
+ import { McpGateClient } from '../../services/mcp/gate-client.js';
8
+ export const McpRevokeDataSchema = z.object({ slug: z.string(), gate_revoked: z.boolean(), cloudflare_deleted: z.boolean(), local_config_deleted: z.boolean() });
9
+ export default class McpRevoke extends BaseCommand {
10
+ static errors = [ErrorCode.ConfigInvalid, ErrorCode.AuthRequired, ErrorCode.GateUnavailable, ErrorCode.ApiError];
11
+ static summary = 'Revoke an MCP machine and optionally delete Cloudflare resources';
12
+ static examples = ['<%= config.bin %> mcp revoke --slug my-repo', '<%= config.bin %> mcp revoke --slug my-repo --cloudflare --yes'];
13
+ static enableJsonFlag = true;
14
+ static strict = true;
15
+ static flags = {
16
+ slug: Flags.string({ description: 'Machine slug. Optional only when one config exists.' }),
17
+ cloudflare: Flags.boolean({ description: 'Also delete Cloudflare DNS record and tunnel.', default: false }),
18
+ yes: Flags.boolean({ char: 'y', description: 'Skip confirmation for --cloudflare.', default: false }),
19
+ 'cf-account-id': Flags.string({ description: 'Cloudflare account ID. Defaults to CLOUDFLARE_ACCOUNT_ID.' }),
20
+ 'cf-zone-id': Flags.string({ description: 'Cloudflare zone ID. Defaults to CLOUDFLARE_ZONE_ID.' }),
21
+ 'cf-api-token': Flags.string({ description: 'Cloudflare API token. Defaults to CLOUDFLARE_API_TOKEN; never persisted.' }),
22
+ };
23
+ async execute() {
24
+ const flags = await this.parseFlags(McpRevoke);
25
+ const config = await resolveMachineConfig(flags.slug);
26
+ await new McpGateClient().revokeMachine(config.slug);
27
+ let cloudflareDeleted = false;
28
+ if (flags.cloudflare) {
29
+ if (!flags.yes)
30
+ throw new Error('refusing to delete Cloudflare resources without --yes');
31
+ const cf = new CloudflareClient({ accountId: flags['cf-account-id'], zoneId: flags['cf-zone-id'], apiToken: flags['cf-api-token'] });
32
+ await cf.deleteDnsRecord(config.dns_record_id);
33
+ await cf.deleteTunnel(config.tunnel_id);
34
+ cloudflareDeleted = true;
35
+ }
36
+ const localConfigDeleted = await deleteMachineConfig(config.slug);
37
+ return { slug: config.slug, gate_revoked: true, cloudflare_deleted: cloudflareDeleted, local_config_deleted: localConfigDeleted };
38
+ }
39
+ }
40
+ //# sourceMappingURL=revoke.js.map
@@ -0,0 +1,75 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { z } from 'zod';
3
+ import { ErrorCode } from '@eightstate/contracts/errors';
4
+ import { BaseCommand } from '../../base-command.js';
5
+ import { writeStderr } from '../../io/io.js';
6
+ import { usageError } from '../../services/mcp/common.js';
7
+ import { resolveMachineConfig, validateRootDirectory } from '../../services/mcp/config.js';
8
+ import { startCloudflared } from '../../services/mcp/cloudflared.js';
9
+ import { startMcpServer } from '../../services/mcp/server.js';
10
+ export const McpServeDataSchema = z.object({ slug: z.string(), root: z.string(), local_url: z.string(), connector_url: z.string(), port: z.number() });
11
+ export default class McpServe extends BaseCommand {
12
+ static errors = [ErrorCode.UsageInvalid, ErrorCode.ConfigInvalid, ErrorCode.FileNotFound, ErrorCode.Internal];
13
+ static summary = 'Serve the local MCP origin and Cloudflare tunnel';
14
+ static examples = ['<%= config.bin %> mcp serve --root .', '<%= config.bin %> mcp serve --root . --slug my-repo --verbose'];
15
+ static enableJsonFlag = true;
16
+ static strict = true;
17
+ static flags = {
18
+ root: Flags.string({ description: 'Root directory to expose.', required: true }),
19
+ slug: Flags.string({ description: 'Machine slug. Optional only when one config exists.' }),
20
+ port: Flags.integer({ description: 'Override local MCP origin port.' }),
21
+ verbose: Flags.boolean({ description: 'Print redacted per-call and cloudflared logs.', default: false }),
22
+ };
23
+ async execute() {
24
+ const flags = await this.parseFlags(McpServe);
25
+ const root = await validateRootDirectory(flags.root);
26
+ const config = await resolveMachineConfig(flags.slug);
27
+ const port = flags.port ?? config.port;
28
+ if (port < 1 || port > 65535)
29
+ throw usageError('--port must be between 1 and 65535');
30
+ const log = async (line) => {
31
+ if (flags.verbose && !flags.quiet && !flags.json)
32
+ await writeStderr(`${line.trimEnd()}\n`);
33
+ };
34
+ const server = await startMcpServer({ root, config: { ...config, port }, port, verbose: (line) => void log(line) });
35
+ let shuttingDown = false;
36
+ const runtime = {};
37
+ const shutdown = async (exitCode) => {
38
+ if (shuttingDown)
39
+ return;
40
+ shuttingDown = true;
41
+ await runtime.cloudflared?.stop().catch(() => { });
42
+ await server.close().catch(() => { });
43
+ process.exitCode = exitCode;
44
+ };
45
+ try {
46
+ runtime.cloudflared = await startCloudflared(config.tunnel_token, {
47
+ log: (line) => void log(line),
48
+ onExit: (code, signal) => {
49
+ void writeStderr(`cloudflared exited (${code ?? signal ?? 'unknown'})\n`).then(() => shutdown(1));
50
+ },
51
+ });
52
+ }
53
+ catch (error) {
54
+ await server.close().catch(() => { });
55
+ throw error;
56
+ }
57
+ const onSignal = () => { void shutdown(0); };
58
+ process.once('SIGINT', onSignal);
59
+ process.once('SIGTERM', onSignal);
60
+ if (!flags.quiet && !flags.json) {
61
+ await writeStderr(`local MCP: ${server.url}\nconnector URL: ${config.resource}/mcp\ntunnel: running for ${config.hostname}\n`);
62
+ }
63
+ this.humanOutputHandled = true;
64
+ await new Promise((resolve) => {
65
+ const timer = setInterval(() => {
66
+ if (shuttingDown) {
67
+ clearInterval(timer);
68
+ resolve();
69
+ }
70
+ }, 100);
71
+ });
72
+ return { slug: config.slug, root, local_url: server.url, connector_url: `${config.resource}/mcp`, port };
73
+ }
74
+ }
75
+ //# sourceMappingURL=serve.js.map
@@ -0,0 +1,67 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { z } from 'zod';
3
+ import { hostname as osHostname } from 'node:os';
4
+ import { createConnection } from 'node:net';
5
+ import { ErrorCode } from '@eightstate/contracts/errors';
6
+ import { BaseCommand } from '../../base-command.js';
7
+ import { findCloudflared } from '../../services/mcp/cloudflared.js';
8
+ import { machineConfigPath, resolveMachineConfig } from '../../services/mcp/config.js';
9
+ import { McpGateClient } from '../../services/mcp/gate-client.js';
10
+ export const McpStatusDataSchema = z.object({
11
+ slug: z.string(),
12
+ config_path: z.string(),
13
+ hostname: z.string(),
14
+ local_hostname: z.string(),
15
+ resource: z.string(),
16
+ port: z.number(),
17
+ listening: z.boolean(),
18
+ cloudflared_installed: z.boolean(),
19
+ gate: z.object({ available: z.boolean(), revoked_at: z.string().nullable().optional(), registered: z.boolean().optional(), error: z.string().optional() }),
20
+ });
21
+ export default class McpStatus extends BaseCommand {
22
+ static errors = [ErrorCode.ConfigInvalid];
23
+ static summary = 'Show MCP machine status';
24
+ static examples = ['<%= config.bin %> mcp status', '<%= config.bin %> mcp status --slug my-repo'];
25
+ static enableJsonFlag = true;
26
+ static strict = true;
27
+ static flags = {
28
+ slug: Flags.string({ description: 'Machine slug. Optional only when one config exists.' }),
29
+ };
30
+ async execute() {
31
+ const flags = await this.parseFlags(McpStatus);
32
+ const config = await resolveMachineConfig(flags.slug);
33
+ const [listening, cloudflaredInstalled, gate] = await Promise.all([
34
+ isListening(config.port),
35
+ findCloudflared().then(() => true).catch(() => false),
36
+ new McpGateClient().getMachineStatus(config.slug)
37
+ .then((status) => ({ available: true, registered: true, revoked_at: status.revoked_at }))
38
+ .catch((error) => ({ available: false, error: error instanceof Error ? error.message : String(error) })),
39
+ ]);
40
+ return {
41
+ slug: config.slug,
42
+ config_path: machineConfigPath(config.slug),
43
+ hostname: config.hostname,
44
+ local_hostname: osHostname(),
45
+ resource: config.resource,
46
+ port: config.port,
47
+ listening,
48
+ cloudflared_installed: cloudflaredInstalled,
49
+ gate,
50
+ };
51
+ }
52
+ }
53
+ function isListening(port) {
54
+ return new Promise((resolve) => {
55
+ const socket = createConnection({ host: '127.0.0.1', port });
56
+ socket.once('connect', () => {
57
+ socket.destroy();
58
+ resolve(true);
59
+ });
60
+ socket.once('error', () => resolve(false));
61
+ socket.setTimeout(1000, () => {
62
+ socket.destroy();
63
+ resolve(false);
64
+ });
65
+ });
66
+ }
67
+ //# sourceMappingURL=status.js.map
@@ -1,7 +1,7 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { renderKv } from '../../../io/render-kv.js';
3
3
  import { notionCommentsCreate } from '../../../services/notion.js';
4
- import { createBodyFromMarkdown, formatAuthor, formatCreated, joinMarkdown, kvEntries, notionCommentErrors, NotionCommentsCommand, parseTarget, shapeReceipt, } from '../../../lib/notion/comments/shared.js';
4
+ import { createBodyFromMarkdown, createBodyWithRichTextMentions, formatAuthor, formatCreated, joinMarkdown, kvEntries, notionCommentErrors, NotionCommentsCommand, parseTarget, resolveCommentMentions, shapeReceipt, } from '../../../lib/notion/comments/shared.js';
5
5
  export default class NotionCommentsAdd extends NotionCommentsCommand {
6
6
  static errors = notionCommentErrors;
7
7
  static summary = 'Add a Notion comment to a page or block';
@@ -13,6 +13,7 @@ export default class NotionCommentsAdd extends NotionCommentsCommand {
13
13
  static aliases = [];
14
14
  static flags = {
15
15
  raw: Flags.boolean({ description: 'Emit the verbatim Notion response body.', default: false }),
16
+ mention: Flags.string({ description: 'Resolve @name/email/user-id placeholders to real Notion user mentions. Repeat or comma-separate.', multiple: true }),
16
17
  };
17
18
  static args = {
18
19
  target: Args.string({ description: 'Page or block ID/URL.', required: true }),
@@ -24,12 +25,14 @@ export default class NotionCommentsAdd extends NotionCommentsCommand {
24
25
  const { args, flags } = await this.parse(NotionCommentsAdd);
25
26
  const target = parseTarget(args.target);
26
27
  const markdown = joinMarkdown(args.markdown);
27
- const result = await notionCommentsCreate({ body: createBodyFromMarkdown(target, markdown) });
28
+ const resolution = await resolveCommentMentions(markdown, flags.mention ?? []);
29
+ const body = resolution.mentions.length > 0 ? createBodyWithRichTextMentions(target, markdown, resolution.mentions) : createBodyFromMarkdown(target, markdown);
30
+ const result = await notionCommentsCreate({ body });
28
31
  this.setNext(null);
29
32
  if (flags.raw)
30
33
  return this.setRaw(result.raw);
31
34
  const action = target.kind === 'block' ? 'created_block_comment' : 'created_page_comment';
32
- return shapeReceipt(result, { action, where: `${target.kind} ${target.id}`, body: markdown });
35
+ return shapeReceipt(result, { action, where: `${target.kind} ${target.id}`, body: markdown, warnings: resolution.warnings });
33
36
  }
34
37
  render(data) {
35
38
  const receipt = data;
@@ -41,6 +44,7 @@ export default class NotionCommentsAdd extends NotionCommentsCommand {
41
44
  author: formatAuthor({ author: receipt.author, author_kind: receipt.author_kind }),
42
45
  created_time: formatCreated(receipt.created_time),
43
46
  warning: receipt.warning,
47
+ warnings: receipt.warnings?.join('; '),
44
48
  body: receipt.body,
45
49
  }));
46
50
  }
@@ -1,7 +1,7 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { renderKv } from '../../../io/render-kv.js';
3
3
  import { notionCommentsCreate } from '../../../services/notion.js';
4
- import { formatAuthor, formatCreated, joinMarkdown, kvEntries, notionCommentErrors, NotionCommentsCommand, replyBodyFromMarkdown, shapeReceipt, } from '../../../lib/notion/comments/shared.js';
4
+ import { formatAuthor, formatCreated, joinMarkdown, kvEntries, notionCommentErrors, NotionCommentsCommand, replyBodyFromMarkdown, replyBodyWithRichTextMentions, resolveCommentMentions, shapeReceipt, } from '../../../lib/notion/comments/shared.js';
5
5
  export default class NotionCommentsReply extends NotionCommentsCommand {
6
6
  static errors = notionCommentErrors;
7
7
  static summary = 'Reply to a Notion discussion thread';
@@ -13,6 +13,7 @@ export default class NotionCommentsReply extends NotionCommentsCommand {
13
13
  static aliases = [];
14
14
  static flags = {
15
15
  raw: Flags.boolean({ description: 'Emit the verbatim Notion response body.', default: false }),
16
+ mention: Flags.string({ description: 'Resolve @name/email/user-id placeholders to real Notion user mentions. Repeat or comma-separate.', multiple: true }),
16
17
  };
17
18
  static args = {
18
19
  discussionId: Args.string({ description: 'Discussion ID.', required: true }),
@@ -23,11 +24,13 @@ export default class NotionCommentsReply extends NotionCommentsCommand {
23
24
  async execute() {
24
25
  const { args, flags } = await this.parse(NotionCommentsReply);
25
26
  const markdown = joinMarkdown(args.markdown);
26
- const result = await notionCommentsCreate({ body: replyBodyFromMarkdown(args.discussionId, markdown) });
27
+ const resolution = await resolveCommentMentions(markdown, flags.mention ?? []);
28
+ const body = resolution.mentions.length > 0 ? replyBodyWithRichTextMentions(args.discussionId, markdown, resolution.mentions) : replyBodyFromMarkdown(args.discussionId, markdown);
29
+ const result = await notionCommentsCreate({ body });
27
30
  this.setNext(null);
28
31
  if (flags.raw)
29
32
  return this.setRaw(result.raw);
30
- return shapeReceipt(result, { action: 'replied_to_discussion', body: markdown, discussionId: args.discussionId });
33
+ return shapeReceipt(result, { action: 'replied_to_discussion', body: markdown, discussionId: args.discussionId, warnings: resolution.warnings });
31
34
  }
32
35
  render(data) {
33
36
  const receipt = data;
@@ -38,6 +41,7 @@ export default class NotionCommentsReply extends NotionCommentsCommand {
38
41
  author: formatAuthor({ author: receipt.author, author_kind: receipt.author_kind }),
39
42
  created_time: formatCreated(receipt.created_time),
40
43
  warning: receipt.warning,
44
+ warnings: receipt.warnings?.join('; '),
41
45
  body: receipt.body,
42
46
  }));
43
47
  }
@@ -74,8 +74,8 @@ export default class NotionDbCreate extends BaseCommand {
74
74
  const dataSourceName = typedFlags['data-source-name'] ?? args.title;
75
75
  const views = [...parseViewFlags(typedFlags.view), ...await parseViewFileInput(typedFlags['view-file'])];
76
76
  const request = {
77
- parent: typedFlags.parent,
78
- title: args.title,
77
+ parent: databaseParentFromFlag(typedFlags.parent),
78
+ title: richTextTitle(args.title),
79
79
  initial_data_source: { name: dataSourceName, schema },
80
80
  views,
81
81
  };
@@ -198,6 +198,15 @@ export async function parseViewFileInput(value) {
198
198
  const views = Array.isArray(parsed) ? parsed : [parsed];
199
199
  return views.map((view, index) => parseViewRecord(view, `view-file[${index}]`));
200
200
  }
201
+ export function databaseParentFromFlag(parent) {
202
+ const trimmed = parent.trim();
203
+ if (trimmed === 'workspace')
204
+ return { type: 'workspace', workspace: true };
205
+ return { type: 'page_id', page_id: normalizeNotionId(trimmed) };
206
+ }
207
+ export function richTextTitle(content) {
208
+ return [{ type: 'text', text: { content } }];
209
+ }
201
210
  export function parseViewFlags(values) {
202
211
  return (values ?? []).map((value) => {
203
212
  const separator = value.indexOf(':');
@@ -478,17 +487,29 @@ function findRawView(value) {
478
487
  return values.map(recordValue).find((record) => record?.object === 'view');
479
488
  }
480
489
  function locationFromRaw(value) {
481
- const databaseId = stringValue(value?.database_id);
490
+ const databaseId = stringValue(value?.database_id) ?? parentId(value?.parent, 'database_id');
482
491
  if (databaseId)
483
492
  return { database_id: databaseId };
484
- const dashboardId = stringValue(value?.dashboard_view_id);
493
+ const dashboardId = stringValue(value?.dashboard_view_id) ?? parentId(value?.parent, 'view_id');
485
494
  if (dashboardId)
486
495
  return { dashboard_view_id: dashboardId };
487
- const pageId = stringValue(value?.page_id);
496
+ const pageId = stringValue(value?.page_id) ?? parentId(value?.parent, 'page_id');
488
497
  if (pageId)
489
498
  return { page_id: pageId };
490
499
  return undefined;
491
500
  }
501
+ function parentId(parent, key) {
502
+ return stringValue(recordValue(parent)?.[key]);
503
+ }
504
+ function normalizeNotionId(value) {
505
+ const uuid = value.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/iu)?.[0];
506
+ if (uuid)
507
+ return uuid.toLowerCase();
508
+ const compact = value.match(/[0-9a-f]{32}/iu)?.[0];
509
+ if (!compact)
510
+ return value;
511
+ return `${compact.slice(0, 8)}-${compact.slice(8, 12)}-${compact.slice(12, 16)}-${compact.slice(16, 20)}-${compact.slice(20)}`.toLowerCase();
512
+ }
492
513
  function parentField(parent, fallback) {
493
514
  const record = recordValue(parent);
494
515
  const pageId = stringValue(record?.page_id);
@@ -45,9 +45,9 @@ export default class NotionDsCreate extends BaseCommand {
45
45
  const schema = await parseSchemaInput(typedFlags['schema-input']);
46
46
  const views = [...parseViewFlags(typedFlags.view), ...await parseViewFileInput(typedFlags['view-file'])];
47
47
  const request = {
48
- parent: { database_id: args.database_id },
49
- name: args.name,
50
- schema,
48
+ parent: { type: 'database_id', database_id: args.database_id },
49
+ title: [{ type: 'text', text: { content: args.name } }],
50
+ properties: schema,
51
51
  views,
52
52
  };
53
53
  if (flags['dry-run']) {
@@ -28,12 +28,9 @@ export default class NotionPageReplaceContent extends NotionWriteCommand {
28
28
  requireYes(flags.yes);
29
29
  const pageId = parseNotionId(args.page);
30
30
  const input = await readMarkdownInput(flags.file, flags.text);
31
- // Canonical replace_content body — matches notion page replace and the
32
- // page-content test contract. allow_deleting_content stays top-level and
33
- // is only emitted when true.
34
31
  const body = {
35
32
  type: 'replace_content',
36
- replace_content: { markdown: input.markdown },
33
+ replace_content: { new_str: input.markdown },
37
34
  ...(flags['allow-deleting-content'] ? { allow_deleting_content: true } : {}),
38
35
  };
39
36
  const result = await notionPagePatchMarkdown(pageId, body);
@@ -28,13 +28,10 @@ export default class NotionPageReplaceText extends NotionWriteCommand {
28
28
  this.outputFlags = flags;
29
29
  requireYes(flags.yes);
30
30
  const pageId = parseNotionId(args.page);
31
- // Canonical update_content body — matches notion page edit and the
32
- // page-content test contract. allow_deleting_content stays top-level and
33
- // is only emitted when true; never nested inside update_content.
34
31
  const body = {
35
32
  type: 'update_content',
36
33
  update_content: {
37
- content_updates: [{ old_str: flags.old, new_str: flags.new, replace_all_matches: flags['replace-all'] }],
34
+ content_updates: [{ old_str: flags.old, new_str: flags.new, ...(flags['replace-all'] ? { replace_all_matches: true } : {}) }],
38
35
  },
39
36
  ...(flags['allow-deleting-content'] ? { allow_deleting_content: true } : {}),
40
37
  };
@@ -9,6 +9,22 @@ import { EscliError } from '../../../lib/escli-error.js';
9
9
  import { hasManifestFlag } from '../../../lib/notion/manifest-pass.js';
10
10
  const MIB = 1024 * 1024;
11
11
  export { FileUploadReceiptSchema as NotionUploadDataSchema };
12
+ function contentTypeForName(name) {
13
+ const lower = name.toLowerCase();
14
+ if (lower.endsWith('.txt') || lower.endsWith('.md') || lower.endsWith('.csv') || lower.endsWith('.json'))
15
+ return 'text/plain; charset=utf-8';
16
+ if (lower.endsWith('.png'))
17
+ return 'image/png';
18
+ if (lower.endsWith('.jpg') || lower.endsWith('.jpeg'))
19
+ return 'image/jpeg';
20
+ if (lower.endsWith('.gif'))
21
+ return 'image/gif';
22
+ if (lower.endsWith('.webp'))
23
+ return 'image/webp';
24
+ if (lower.endsWith('.pdf'))
25
+ return 'application/pdf';
26
+ return undefined;
27
+ }
12
28
  export default class NotionUpload extends BaseCommand {
13
29
  static errors = fileUploadErrors;
14
30
  static summary = 'Upload local files to Notion file_upload storage';
@@ -89,18 +105,19 @@ export default class NotionUpload extends BaseCommand {
89
105
  const name = flags.name ?? file.name;
90
106
  const mode = decideMode(file.size, flags.mode);
91
107
  const parts = mode === 'multi_part' ? Math.max(1, Math.ceil(file.size / (flags['part-size-mib'] * MIB))) : undefined;
92
- const created = await notionFileUploadCreate({ mode, filename: name, name, ...(parts ? { number_of_parts: parts } : {}) });
108
+ const contentType = contentTypeForName(name);
109
+ const created = await notionFileUploadCreate({ mode, filename: name, name, ...(contentType ? { content_type: contentType } : {}), ...(parts ? { number_of_parts: parts } : {}) });
93
110
  rawChain.push(created.raw);
94
111
  const id = fileUploadId(created.data);
95
112
  if (mode === 'single_part') {
96
- const sent = await notionFileUploadSend(id, { file: file.bytes, filename: name });
113
+ const sent = await notionFileUploadSend(id, { file: file.bytes, filename: name, contentType });
97
114
  rawChain.push(sent.raw);
98
115
  }
99
116
  else {
100
117
  for (let index = 0; index < (parts ?? 1); index += 1) {
101
118
  const start = index * flags['part-size-mib'] * MIB;
102
119
  const end = Math.min(file.bytes.length, start + flags['part-size-mib'] * MIB);
103
- const sent = await notionFileUploadSend(id, { file: file.bytes.subarray(start, end), filename: name });
120
+ const sent = await notionFileUploadSend(id, { file: file.bytes.subarray(start, end), filename: name, contentType, partNumber: index + 1 });
104
121
  rawChain.push(sent.raw);
105
122
  }
106
123
  const completed = await notionFileUploadComplete(id);
@@ -45,11 +45,13 @@ export default class NotionViewCreate extends BaseCommand {
45
45
  }
46
46
  async execute() {
47
47
  const { args, flags } = await this.parse(NotionViewCreate);
48
+ const location = viewLocation(flags);
48
49
  const request = {
49
50
  name: args.name,
50
51
  data_source_id: flags['data-source'],
51
52
  type: parseViewType(flags.type),
52
- location: viewLocation(flags),
53
+ ...location,
54
+ location,
53
55
  filter: await parseOptionalJsonInput(flags.filter, 'filter'),
54
56
  sorts: parseSortFlags(flags.sort),
55
57
  config: await parseOptionalJsonInput(flags.config, 'config'),
@@ -64,5 +64,5 @@ function inferOptionType(name, value) {
64
64
  return 'integer';
65
65
  return 'string';
66
66
  }
67
- const integerFlags = new Set(['compression', 'speakers-expected', 'limit', 'tokens', 'page', 'max-results', 'days', 'timeout', 'max-wait']);
67
+ const integerFlags = new Set(['compression', 'speakers-expected', 'limit', 'tokens', 'page', 'max-results', 'days', 'timeout', 'max-wait', 'port']);
68
68
  //# sourceMappingURL=manifest.js.map