create-ncblock 0.0.16 → 0.0.18

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.16",
3
+ "version": "0.0.18",
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,12 @@ 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"
17
+ import { resolveInitArgs } from "./utils/resolveInitArgs"
12
18
  import {
13
19
  getTemplateByName,
14
20
  getTemplates,
@@ -18,8 +24,8 @@ import {
18
24
 
19
25
  /**
20
26
  * Seed `.notion/target.json` with whatever's known at scaffold time. Empty
21
- * defaults are intentional — subsequent commands (`manifest pull`, `connect`,
22
- * `deploy`) all merge into this file.
27
+ * defaults are intentional — subsequent `ncblock connect` / `deploy` calls
28
+ * merge into this file.
23
29
  */
24
30
  type InitialTarget = {
25
31
  env: string
@@ -57,6 +63,10 @@ function writeInitialTarget(
57
63
  *
58
64
  * Env is sourced from target.json (written by writeInitialTarget before
59
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.
60
70
  */
61
71
  function runNcblock(dest: string, args: string[]): void {
62
72
  const result = spawnSync(
@@ -64,9 +74,10 @@ function runNcblock(dest: string, args: string[]): void {
64
74
  [resolve(dest, "node_modules/ncblock/bin/cli/cli.js"), ...args],
65
75
  { cwd: dest, stdio: "inherit" },
66
76
  )
77
+ if (result.status === EXIT_USER_ID_MISTAKE) {
78
+ process.exit(EXIT_USER_ID_MISTAKE)
79
+ }
67
80
  if (result.status !== 0) {
68
- // Child already printed a user-friendly message; throw so the caller's
69
- // try/catch can suppress without us having to forward stderr.
70
81
  throw new Error(
71
82
  `ncblock ${args.join(" ")} exited with code ${result.status}`,
72
83
  )
@@ -563,18 +574,32 @@ async function main() {
563
574
  args.install as boolean | undefined,
564
575
  )
565
576
 
566
- // Ask for the database URL/ID upfront — alongside the other prompts. The
567
- // prompt itself only fires when we'll actually install (no manifest pull
568
- // otherwise), but --collection is always honored.
569
- const collectionAnswer =
570
- (args.collection as string | undefined) ??
571
- (installDeps
577
+ // Precedence: --collection > --block/positional > prompt. Classification
578
+ // downstream walks a positional block/view ID up to its parent database.
579
+ const blockIdArg = args.block as string | undefined
580
+ const promptedCollection =
581
+ args.collection === undefined && blockIdArg === undefined && installDeps
572
582
  ? (await ask("Database URL/ID (enter to skip):", "skip")).trim()
573
- : "skip")
574
- const collectionId =
575
- collectionAnswer && collectionAnswer !== "skip"
576
- ? collectionAnswer
577
583
  : undefined
584
+ const collectionOverride =
585
+ (args.collection as string | undefined) ??
586
+ (promptedCollection && promptedCollection !== "skip"
587
+ ? promptedCollection
588
+ : undefined)
589
+
590
+ const resolved = resolveInitArgs({
591
+ collection: collectionOverride,
592
+ block: blockIdArg,
593
+ envBlockId: process.env.BLOCK_ID,
594
+ envName: process.env.ENV,
595
+ })
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
578
603
 
579
604
  printSummary({
580
605
  dest,
@@ -615,20 +640,19 @@ async function main() {
615
640
  step("Initialized git repo")
616
641
  }
617
642
 
618
- const blockId =
619
- (args.block as string | undefined) || process.env.BLOCK_ID || undefined
620
- const envName = process.env.ENV
621
-
622
- // Always seed .notion/target.json so every project starts in a known
623
- // state. env / block_id are populated from what we have; data_sources
624
- // gets filled in by `manifest pull` if it runs.
625
- writeInitialTarget(dest, { env: envName, blockId })
643
+ writeInitialTarget(dest, { env: resolved.env, blockId })
626
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
+ }
627
650
 
651
+ let connected = false
628
652
  if (installDeps) {
629
653
  // Close the readline interface before shelling out — otherwise it
630
654
  // holds onto stdin/stdout and the inherited stdio in child processes
631
- // gets buffered, which makes pull-manifest output land out of order.
655
+ // gets buffered, which makes ncblock output land out of order.
632
656
  closePromptInterface()
633
657
 
634
658
  console.log("")
@@ -636,27 +660,16 @@ async function main() {
636
660
  execSync(`${pm} ${installArgs}`, { cwd: dest, stdio: "inherit" })
637
661
  step("Installed dependencies")
638
662
 
639
- // `manifest pull` resolves a database / data source / collection_view
640
- // block URL to a manifest entry AND merges the resolved data_source_id
641
- // into target.json's data_sources. Then `connect` (no flags needed —
642
- // reads everything from target.json) PATCHes the block if one's set.
643
- let pulled = false
644
- if (collectionId) {
663
+ if (databaseId) {
645
664
  console.log("")
646
665
  try {
647
- runNcblock(dest, ["manifest", "pull", collectionId])
648
- step("Updated custom_blocks.json from collection schema")
649
- pulled = true
650
- } catch {
651
- // manifest pull already printed a user-friendly message
652
- }
653
- }
654
-
655
- if (pulled && blockId) {
656
- console.log("")
657
- try {
658
- runNcblock(dest, ["connect"])
659
- 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
660
673
  } catch {
661
674
  // connect already printed a user-friendly message
662
675
  }
@@ -674,13 +687,21 @@ async function main() {
674
687
  }
675
688
  console.log(` ${pm} run build`)
676
689
  if (deployCommand) {
677
- console.log(` ${deployCommand}\n`)
690
+ console.log(` ${deployCommand}`)
678
691
  } else {
679
692
  console.log(
680
- `\n Then, go back to the instructions in Notion and paste the ${c.bold}ntn deploy${c.reset} command.\n`,
693
+ `\n Then, go back to the instructions in Notion and paste the ${c.bold}ntn deploy${c.reset} command.`,
681
694
  )
682
695
  }
683
696
 
697
+ if (installDeps && !connected) {
698
+ console.log(
699
+ `\n ${c.yellow}No data source connected yet.${c.reset} To wire one up:`,
700
+ )
701
+ console.log(` npx ncblock connect <database-url-or-id>`)
702
+ }
703
+ console.log("")
704
+
684
705
  closePromptInterface()
685
706
  }
686
707
 
@@ -2,6 +2,16 @@
2
2
 
3
3
  A **Notion custom view** — a sandboxed `<iframe>` rendered inside a Notion block. The iframe is the entire viewport; the only channel to the host is a `postMessage` bridge wrapped by `ncblock`.
4
4
 
5
+ ## Connecting to a Notion database
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, then run:
8
+
9
+ ```bash
10
+ npx ncblock connect <database-url-or-id>
11
+ ```
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
+
5
15
  ## Talking to the host
6
16
 
7
17
  - Always use the React hooks from `ncblock`. Never call `window.parent.postMessage` directly — the SDK owns the protocol.
@@ -32,7 +42,6 @@ Hooks at a glance:
32
42
 
33
43
  - Mount into `<div id="root">` — `useCustomBlockAutoResize` looks for that exact id.
34
44
  - `custom_blocks.json` at the project root defines the data contract. The SDK's Vite plugin serves it in dev; `ntn` bundles it into the deploy.
35
- - `custom_blocks.json` is the **portable contract** — no IDs. `.notion/target.json` holds the per-deploy IDs (block IDs, data-source IDs, resolved property IDs) and is committed alongside it.
36
45
 
37
46
  ## Previewing during development
38
47
 
@@ -45,23 +54,6 @@ Two options:
45
54
  cd custom && pnpm install && pnpm run dev:shell # http://localhost:9875
46
55
  ```
47
56
 
48
- ## Connecting to Notion
49
-
50
- `ncblock` cli provides helpers to wire a scaffolded project to a Notion block and data source. Wirings are saved to `.notion/target.json`. Check the output of the cli for latest syntax instead of copying these commands verbatim.
51
-
52
- ```bash
53
- # Pull a schema manifest from a Notion database into custom_blocks.json
54
- npx ncblock manifest pull <db-id-or-url>
55
-
56
- # Connect a local project to a Notion block and/or data source.
57
- npx ncblock connect
58
-
59
- # Deploy a dist/ directory to a block.
60
- npx ncblock deploy
61
- ```
62
-
63
- Templates will often already have some of these set in `.notion/target.json`.
64
-
65
57
  ## Where to look next
66
58
 
67
59
  - `node_modules/ncblock/README.md` — landing page with a TOC into the per-category docs below.
@@ -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
+ }
@@ -0,0 +1,50 @@
1
+ /**
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`.
6
+ *
7
+ * Kept out of `init.ts` so the CLI/env precedence rules are unit-testable
8
+ * without spawning a tsx subprocess.
9
+ */
10
+
11
+ export type InitArgsInput = {
12
+ /** Value of --collection, if explicitly passed. */
13
+ collection?: string
14
+ /** Value of --block or a positional Notion ID, if passed. */
15
+ block?: string
16
+ /** `process.env.BLOCK_ID` — last-resort source for the block ID. */
17
+ envBlockId?: string
18
+ /** `process.env.ENV` — populates `.notion/target.json#env`. */
19
+ envName?: string
20
+ }
21
+
22
+ export type InitArgsResolution = {
23
+ /** Block ID (from --block, positional, or BLOCK_ID env). */
24
+ blockId: 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. */
38
+ env: string
39
+ }
40
+
41
+ export function resolveInitArgs(input: InitArgsInput): InitArgsResolution {
42
+ const blockId = input.block || input.envBlockId || undefined
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
48
+ const env = input.envName ?? "production"
49
+ return { blockId, databaseId, idToClassify, env }
50
+ }
package/sdk-version.json CHANGED
@@ -1 +1 @@
1
- {"version":"0.0.14"}
1
+ {"version":"0.0.16"}