create-ncblock 0.0.17 → 0.0.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ncblock",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "Create a Notion custom view block project.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/init.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execSync, spawnSync } from "child_process"
2
2
  import { existsSync, mkdirSync, readdirSync, writeFileSync } from "fs"
3
- import { basename, dirname, resolve } from "path"
3
+ import { dirname, resolve } from "path"
4
4
  import {
5
5
  clearScreenDown,
6
6
  createInterface,
@@ -9,6 +9,11 @@ import {
9
9
  moveCursor,
10
10
  } from "readline"
11
11
  import { fileURLToPath } from "url"
12
+ import {
13
+ type Classified,
14
+ classifyPositionalId,
15
+ EXIT_USER_ID_MISTAKE,
16
+ } from "./utils/classifyId"
12
17
  import { resolveInitArgs } from "./utils/resolveInitArgs"
13
18
  import {
14
19
  getTemplateByName,
@@ -19,8 +24,8 @@ import {
19
24
 
20
25
  /**
21
26
  * Seed `.notion/target.json` with whatever's known at scaffold time. Empty
22
- * defaults are intentional — subsequent commands (`manifest pull`, `connect`,
23
- * `deploy`) all merge into this file.
27
+ * defaults are intentional — subsequent `ncblock connect` / `deploy` calls
28
+ * merge into this file.
24
29
  */
25
30
  type InitialTarget = {
26
31
  env: string
@@ -58,6 +63,10 @@ function writeInitialTarget(
58
63
  *
59
64
  * Env is sourced from target.json (written by writeInitialTarget before
60
65
  * any ncblock call) — no need to thread ENV through the spawn.
66
+ *
67
+ * Exits init immediately on `EXIT_USER_ID_MISTAKE` so we don't fall through
68
+ * to "Ready! Next steps:" with a broken target.json. Any other non-zero exit
69
+ * is thrown so the caller's try/catch can suppress without forwarding stderr.
61
70
  */
62
71
  function runNcblock(dest: string, args: string[]): void {
63
72
  const result = spawnSync(
@@ -65,9 +74,10 @@ function runNcblock(dest: string, args: string[]): void {
65
74
  [resolve(dest, "node_modules/ncblock/bin/cli/cli.js"), ...args],
66
75
  { cwd: dest, stdio: "inherit" },
67
76
  )
77
+ if (result.status === EXIT_USER_ID_MISTAKE) {
78
+ process.exit(EXIT_USER_ID_MISTAKE)
79
+ }
68
80
  if (result.status !== 0) {
69
- // Child already printed a user-friendly message; throw so the caller's
70
- // try/catch can suppress without us having to forward stderr.
71
81
  throw new Error(
72
82
  `ncblock ${args.join(" ")} exited with code ${result.status}`,
73
83
  )
@@ -564,11 +574,8 @@ async function main() {
564
574
  args.install as boolean | undefined,
565
575
  )
566
576
 
567
- // Ask for the database URL/ID upfront alongside the other prompts.
568
- // Precedence: --collection > --block/positional > prompt. A positional
569
- // Notion ID is almost always a block inside a collection_view (or the
570
- // view block itself); `manifest pull`'s resolveId() handles both cases,
571
- // so we can feed the block ID straight through without re-prompting.
577
+ // Precedence: --collection > --block/positional > prompt. Classification
578
+ // downstream walks a positional block/view ID up to its parent database.
572
579
  const blockIdArg = args.block as string | undefined
573
580
  const promptedCollection =
574
581
  args.collection === undefined && blockIdArg === undefined && installDeps
@@ -585,9 +592,14 @@ async function main() {
585
592
  block: blockIdArg,
586
593
  envBlockId: process.env.BLOCK_ID,
587
594
  envName: process.env.ENV,
588
- installDeps,
589
595
  })
590
- const { blockId, collectionId } = resolved
596
+ const classified: Classified | null = resolved.idToClassify
597
+ ? classifyPositionalId(resolved.idToClassify, resolved.env)
598
+ : null
599
+ // Explicit --collection wins over classification; classify normalizes
600
+ // the positional input to a dashed UUID and falls back to the raw value.
601
+ const blockId = classified?.blockId ?? resolved.blockId
602
+ const databaseId = resolved.databaseId ?? classified?.databaseId
591
603
 
592
604
  printSummary({
593
605
  dest,
@@ -628,17 +640,19 @@ async function main() {
628
640
  step("Initialized git repo")
629
641
  }
630
642
 
631
- // Always seed .notion/target.json so every project starts in a known
632
- // state. env / block_id are populated from what we have; data_sources
633
- // gets filled in by `manifest pull` if it runs.
634
643
  writeInitialTarget(dest, { env: resolved.env, blockId })
635
644
  step(`Seeded .notion/target.json`)
645
+ if (classified?.kind === "view") {
646
+ console.log(
647
+ ` ${c.dim}(view ID resolved — using as block id, database ${databaseId ?? "(none)"})${c.reset}`,
648
+ )
649
+ }
636
650
 
637
- let pulled = false
651
+ let connected = false
638
652
  if (installDeps) {
639
653
  // Close the readline interface before shelling out — otherwise it
640
654
  // holds onto stdin/stdout and the inherited stdio in child processes
641
- // gets buffered, which makes pull-manifest output land out of order.
655
+ // gets buffered, which makes ncblock output land out of order.
642
656
  closePromptInterface()
643
657
 
644
658
  console.log("")
@@ -646,26 +660,16 @@ async function main() {
646
660
  execSync(`${pm} ${installArgs}`, { cwd: dest, stdio: "inherit" })
647
661
  step("Installed dependencies")
648
662
 
649
- // `manifest pull` resolves a database / data source / collection_view
650
- // block URL to a manifest entry AND merges the resolved data_source_id
651
- // into target.json's data_sources. Then `connect` (no flags needed —
652
- // reads everything from target.json) PATCHes the block if one's set.
653
- if (collectionId) {
663
+ if (databaseId) {
654
664
  console.log("")
655
665
  try {
656
- runNcblock(dest, ["manifest", "pull", collectionId])
657
- step("Updated custom_blocks.json from collection schema")
658
- pulled = true
659
- } catch {
660
- // manifest pull already printed a user-friendly message
661
- }
662
- }
663
-
664
- if (pulled && blockId) {
665
- console.log("")
666
- try {
667
- runNcblock(dest, ["connect"])
668
- step("Wired block bindings (.notion/target.json + PATCH)")
666
+ runNcblock(dest, ["connect", databaseId, "--quiet"])
667
+ step(
668
+ blockId
669
+ ? "Wired block bindings (custom_blocks.json + .notion/target.json + PATCH)"
670
+ : "Recorded data source in custom_blocks.json + .notion/target.json",
671
+ )
672
+ connected = true
669
673
  } catch {
670
674
  // connect already printed a user-friendly message
671
675
  }
@@ -690,20 +694,11 @@ async function main() {
690
694
  )
691
695
  }
692
696
 
693
- // If install ran but no data source was wired (no collectionId, or a
694
- // pull/connect failure), surface the manual CLI commands explicitly.
695
- // Agents read this scrollback — being concrete here keeps them from
696
- // reinventing the bridge or hardcoding IDs.
697
- if (installDeps && !pulled) {
697
+ if (installDeps && !connected) {
698
698
  console.log(
699
699
  `\n ${c.yellow}No data source connected yet.${c.reset} To wire one up:`,
700
700
  )
701
- console.log(` npx ncblock manifest pull <database-url-or-id>`)
702
- if (!blockId) {
703
- console.log(` npx ncblock connect --block <block-id-or-url>`)
704
- } else {
705
- console.log(` npx ncblock connect`)
706
- }
701
+ console.log(` npx ncblock connect <database-url-or-id>`)
707
702
  }
708
703
  console.log("")
709
704
 
@@ -4,12 +4,14 @@ A **Notion custom view** — a sandboxed `<iframe>` rendered inside a Notion blo
4
4
 
5
5
  ## Connecting to a Notion database
6
6
 
7
- If `custom_blocks.json` or `.notion/target.json` doesn't yet reference a datasource, ask the user if they want to connect a database by url or id. Use the `npx ncblock` cli to connect the database and the project. Discover available commands with:
7
+ If `custom_blocks.json` or `.notion/target.json` doesn't yet reference a datasource, ask the user if they want to connect a database by url or id, then run:
8
8
 
9
9
  ```bash
10
- npx ncblock --help
10
+ npx ncblock connect <database-url-or-id>
11
11
  ```
12
12
 
13
+ That writes `custom_blocks.json`, `.notion/target.json`, and PATCHes any blocks already listed there. Discover other commands with `npx ncblock --help`.
14
+
13
15
  ## Talking to the host
14
16
 
15
17
  - Always use the React hooks from `ncblock`. Never call `window.parent.postMessage` directly — the SDK owns the protocol.
@@ -0,0 +1,227 @@
1
+ import { execSync } from "node:child_process"
2
+
3
+ /** Sentinel exit code for "user pasted the wrong kind of ID" errors. */
4
+ export const EXIT_USER_ID_MISTAKE = 2
5
+
6
+ const MAX_PARENT_HOPS = 5
7
+
8
+ const YELLOW = "\x1b[33m"
9
+ const RESET = "\x1b[0m"
10
+
11
+ export type Classified = {
12
+ kind: "block" | "view"
13
+ /** What we'll write to `target.json#block_id`. Equals the user's input. */
14
+ blockId: string
15
+ /** What we'll pass to `ncblock connect` (if known). */
16
+ databaseId?: string
17
+ }
18
+
19
+ /**
20
+ * Resolve the user's positional ID into `{ blockId, databaseId }` BEFORE
21
+ * scaffolding, so bad inputs don't leave a half-written project on disk and
22
+ * good inputs already know which DB to pull from.
23
+ *
24
+ * - `/v1/databases/<id>` succeeds → user pasted a database ID into the
25
+ * block-ID slot. Fail-fast.
26
+ * - `/v1/blocks/<id>` succeeds → it's a block. `blockId` is the input;
27
+ * `databaseId` is walked from `block.parent` (database_id parent, or
28
+ * data_source parent, or recurse through block/page parents).
29
+ * - `/v1/views/<id>` succeeds → it's a view (Notion URL `?v=<id>` form).
30
+ * `blockId` = input (the view ID is the custom_block ID for the deploy
31
+ * target); `databaseId` = `view.parent.database_id`.
32
+ * - All three 404 → wrong workspace. Fail-fast.
33
+ * - Any non-404 failure → surface ntn's stderr and exit with its status,
34
+ * instead of misclassifying as "not in your workspace."
35
+ *
36
+ * Best-effort: if `ntn` isn't on PATH, returns `null` (with a warning logged
37
+ * by `isNtnAvailable`) — `ncblock connect`'s downstream errors will surface
38
+ * real issues.
39
+ */
40
+ export function classifyPositionalId(
41
+ rawId: string,
42
+ env: string,
43
+ ): Classified | null {
44
+ const id = formatDashedUuid(rawId)
45
+
46
+ if (!isNtnAvailable()) {
47
+ return null
48
+ }
49
+
50
+ const dbResult = tryNtnApi(env, `/v1/databases/${id}`)
51
+ if (dbResult.ok && isDatabaseShape(dbResult.body)) {
52
+ bailUserInput(
53
+ `${id} is a database id, please paste the block id or view id`,
54
+ )
55
+ }
56
+
57
+ const blockResult = tryNtnApi(env, `/v1/blocks/${id}`)
58
+ if (blockResult.ok) {
59
+ return {
60
+ kind: "block",
61
+ blockId: id,
62
+ databaseId: walkBlockToDatabase(blockResult.body, env, 0),
63
+ }
64
+ }
65
+
66
+ const viewResult = tryNtnApi(env, `/v1/views/${id}`)
67
+ if (viewResult.ok) {
68
+ const v = viewResult.body as
69
+ | { parent?: { database_id?: string } }
70
+ | undefined
71
+ return { kind: "view", blockId: id, databaseId: v?.parent?.database_id }
72
+ }
73
+
74
+ // Every probe failed. If any returned a non-404, show ntn's actual stderr
75
+ // — saying "not in your workspace" would mislead the user when the real
76
+ // issue is e.g. a 400 validation error. Only the all-404 case gets the
77
+ // wrong-workspace nudge.
78
+ const firstError = [dbResult, blockResult, viewResult].find(r => r.error)
79
+ if (firstError) {
80
+ process.stderr.write(firstError.error ?? "")
81
+ process.exit(firstError.status ?? 1)
82
+ }
83
+
84
+ bailUserInput(
85
+ `${id} doesn't look like a valid id for your workspace. Ensure you're logged in to the right workspace with \`${loginCommandHint(env)}\`.`,
86
+ )
87
+ }
88
+
89
+ /**
90
+ * Walk up a block's parent chain looking for an enclosing database. The
91
+ * common case is `parent.type === "database_id"` (one hop), but blocks can
92
+ * also be children of pages or other blocks, so we recurse up to
93
+ * MAX_PARENT_HOPS.
94
+ */
95
+ function walkBlockToDatabase(
96
+ block: unknown,
97
+ env: string,
98
+ depth: number,
99
+ ): string | undefined {
100
+ if (depth >= MAX_PARENT_HOPS) {
101
+ return undefined
102
+ }
103
+ const parent = (block as { parent?: Record<string, unknown> } | undefined)
104
+ ?.parent
105
+ const parentType = typeof parent?.type === "string" ? parent.type : undefined
106
+ if (!parent || !parentType) {
107
+ return undefined
108
+ }
109
+
110
+ const parentId = parent[parentType]
111
+ if (typeof parentId !== "string") {
112
+ return undefined
113
+ }
114
+
115
+ switch (parentType) {
116
+ case "database_id":
117
+ return parentId
118
+ case "data_source_id": {
119
+ const ds = tryNtnApi(env, `/v1/data_sources/${parentId}`)
120
+ if (!ds.ok) {
121
+ return undefined
122
+ }
123
+ // TODO(custom-blocks): validate the data source response shape with
124
+ // a proper type guard instead of casting.
125
+ const dbId = (
126
+ ds.body as { parent?: { database_id?: string } } | undefined
127
+ )?.parent?.database_id
128
+ return dbId
129
+ }
130
+ case "block_id":
131
+ case "page_id": {
132
+ const parentBlock = tryNtnApi(env, `/v1/blocks/${parentId}`)
133
+ return parentBlock.ok
134
+ ? walkBlockToDatabase(parentBlock.body, env, depth + 1)
135
+ : undefined
136
+ }
137
+ default:
138
+ return undefined
139
+ }
140
+ }
141
+
142
+ function isNtnAvailable(): boolean {
143
+ try {
144
+ execSync("ntn --version", { stdio: "ignore" })
145
+ return true
146
+ } catch {
147
+ console.warn(
148
+ ` ${YELLOW}Warning:${RESET} \`ntn\` not on PATH — skipping ID classification. Install ntn to catch wrong-workspace / wrong-ID-kind mistakes upfront.`,
149
+ )
150
+ return false
151
+ }
152
+ }
153
+
154
+ type NtnApiResult = {
155
+ ok: boolean
156
+ body?: unknown
157
+ /** Captured stderr from a non-404 failure (so the caller can surface it). */
158
+ error?: string
159
+ /** ntn's exit code on failure, for propagation. */
160
+ status?: number
161
+ }
162
+
163
+ /**
164
+ * 404s mean "not this kind of resource" — keep probing. Other failures (400
165
+ * validation errors, 401/403, network) get captured so the classifier can
166
+ * surface them only if every probe fails (i.e. we'd otherwise mislead the user
167
+ * with "not in your workspace").
168
+ */
169
+ function tryNtnApi(env: string, apiPath: string): NtnApiResult {
170
+ const prefix =
171
+ env && env !== "production" ? `NOTION_KEYRING=0 ntn --env ${env}` : "ntn"
172
+ try {
173
+ const out = execSync(`${prefix} api ${apiPath}`, {
174
+ encoding: "utf-8",
175
+ stdio: "pipe",
176
+ })
177
+ try {
178
+ return { ok: true, body: JSON.parse(out) }
179
+ } catch {
180
+ return {
181
+ ok: false,
182
+ error: `Failed to parse successful response from ntn api ${apiPath}`,
183
+ status: 1,
184
+ }
185
+ }
186
+ } catch (err) {
187
+ const stderr =
188
+ (err as { stderr?: Buffer | string })?.stderr?.toString() ?? ""
189
+ if (stderr.includes("404")) {
190
+ return { ok: false }
191
+ }
192
+ const status = (err as { status?: number | null })?.status ?? 1
193
+ return { ok: false, error: stderr, status: status ?? 1 }
194
+ }
195
+ }
196
+
197
+ function loginCommandHint(env: string): string {
198
+ return env && env !== "production"
199
+ ? `NOTION_KEYRING=0 ntn --env ${env} login`
200
+ : "ntn login"
201
+ }
202
+
203
+ function isDatabaseShape(body: unknown): boolean {
204
+ if (typeof body !== "object" || body === null) {
205
+ return false
206
+ }
207
+ if ("object" in body && (body as { object: unknown }).object === "database") {
208
+ return true
209
+ }
210
+ return (
211
+ "data_sources" in body &&
212
+ Array.isArray((body as { data_sources: unknown }).data_sources)
213
+ )
214
+ }
215
+
216
+ function formatDashedUuid(raw: string): string {
217
+ const h = raw.replace(/-/g, "")
218
+ if (h.length !== 32) {
219
+ return raw
220
+ }
221
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
222
+ }
223
+
224
+ function bailUserInput(message: string): never {
225
+ console.error(`\n ${YELLOW}Error:${RESET} ${message}\n`)
226
+ process.exit(EXIT_USER_ID_MISTAKE)
227
+ }
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Pure resolution of init.ts's data-source / block / env wiring decisions.
2
+ * Decide what ID the user is actually passing to the init script — the
3
+ * "parse the inputs" half of init's ID handling. The other half (deciding
4
+ * what an ID *is* — block? view? database in the wrong slot?) lives in
5
+ * `classifyId.ts`.
3
6
  *
4
- * Pulled out of init.ts so the precedence rules especially "positional/--block
5
- * Notion ID should also drive `manifest pull` when no --collection is given" —
6
- * are unit-testable without spawning a tsx subprocess.
7
+ * Kept out of `init.ts` so the CLI/env precedence rules are unit-testable
8
+ * without spawning a tsx subprocess.
7
9
  */
8
10
 
9
11
  export type InitArgsInput = {
@@ -11,49 +13,38 @@ export type InitArgsInput = {
11
13
  collection?: string
12
14
  /** Value of --block or a positional Notion ID, if passed. */
13
15
  block?: string
14
- /** `process.env.BLOCK_ID` — used as a last resort for block resolution. */
16
+ /** `process.env.BLOCK_ID` — last-resort source for the block ID. */
15
17
  envBlockId?: string
16
18
  /** `process.env.ENV` — populates `.notion/target.json#env`. */
17
19
  envName?: string
18
- /** Whether `init` will run install + ncblock pull/connect (--install / --no-install). */
19
- installDeps: boolean
20
20
  }
21
21
 
22
22
  export type InitArgsResolution = {
23
+ /** Block ID (from --block, positional, or BLOCK_ID env). */
23
24
  blockId: string | undefined
24
- collectionId: string | undefined
25
+ /**
26
+ * Explicit --collection value. When set, classification is skipped — we
27
+ * already know the database. When undefined, we'll classify the
28
+ * block-shaped input to figure out the parent DB.
29
+ */
30
+ databaseId: string | undefined
31
+ /**
32
+ * The single ID to feed into `classifyPositionalId`, or `undefined` to
33
+ * skip classification. Set when we have a block-shaped input AND no
34
+ * explicit `--collection` (otherwise the DB is already known).
35
+ */
36
+ idToClassify: string | undefined
37
+ /** Env name for `.notion/target.json#env` and env-aware ntn calls. */
25
38
  env: string
26
- /** Will init invoke `ncblock manifest pull`? */
27
- willPullManifest: boolean
28
- /** Will init invoke `ncblock connect`? (only when pull will also run) */
29
- willConnect: boolean
30
39
  }
31
40
 
32
- /**
33
- * Decide what init will actually do, given the args. Precedence:
34
- *
35
- * 1. `--collection` is the explicit manifest-pull target (highest priority).
36
- * 2. Otherwise, a positional/`--block` Notion ID doubles as the pull target —
37
- * `manifest pull`'s resolveId() handles "block inside collection_view" and
38
- * walks to the parent data source. This is the path users hit with
39
- * `bun create ncblock <block-id>`.
40
- * 3. When neither is set, no pull happens.
41
- *
42
- * `manifest pull` and `connect` only run when install ran (otherwise the
43
- * ncblock CLI isn't on disk to call).
44
- */
45
41
  export function resolveInitArgs(input: InitArgsInput): InitArgsResolution {
46
42
  const blockId = input.block || input.envBlockId || undefined
47
- // A block ID doubles as the pull target: `manifest pull` accepts a block
48
- // (resolveId walks block parent data source), and that's the path
49
- // users hit with `bun create ncblock <block-id>`. Derived from `blockId`
50
- // rather than `input.block` directly so envBlockId flows through too —
51
- // no point treating `--block` and `BLOCK_ID=` differently.
52
- const collectionId = input.collection ?? blockId
43
+ const databaseId = input.collection
44
+ // `--collection` is the user's assertion that they already know the
45
+ // database; otherwise we need to probe the block-shaped input to find
46
+ // the parent DB.
47
+ const idToClassify = databaseId === undefined ? blockId : undefined
53
48
  const env = input.envName ?? "production"
54
-
55
- const willPullManifest = input.installDeps && collectionId !== undefined
56
- const willConnect = willPullManifest && blockId !== undefined
57
-
58
- return { blockId, collectionId, env, willPullManifest, willConnect }
49
+ return { blockId, databaseId, idToClassify, env }
59
50
  }
package/sdk-version.json CHANGED
@@ -1 +1 @@
1
- {"version":"0.0.15"}
1
+ {"version":"0.0.17"}