757 lines
26 KiB
Markdown
757 lines
26 KiB
Markdown
# Codium Nostr Extension — Full Implementation Plan
|
||
|
||
Publish `.md` files as NIP-23 long-form notes (kind `30023`) to Nostr relays
|
||
from inside Codium/VSCode. Two signing backends:
|
||
|
||
- **Phase 1** — `LocalSigner`: user enters `nsec1...` or hex secret key, kept
|
||
in-memory only (no persistence), signs locally with `nostr-tools`'
|
||
`PlainKeySigner`.
|
||
- **Phase 2** — `NsignerBackend`: delegates signing to a running `n_signer`
|
||
process over a Linux abstract Unix socket via length-prefixed JSON-RPC.
|
||
|
||
Both backends implement the `Signer` interface from
|
||
[`nostr-tools/signer.ts`](../nostr-tools/signer.ts:4) — the extension does
|
||
**not** define its own signer abstraction; it reuses the upstream one.
|
||
|
||
Companion design doc: [`longform-metadata-strategy.md`](longform-metadata-strategy.md).
|
||
|
||
---
|
||
|
||
## 1. Project layout
|
||
|
||
```
|
||
codium_nostr_extension/
|
||
├── package.json # extension manifest, commands, config schema
|
||
├── tsconfig.json
|
||
├── esbuild.mjs # bundler config (commonjs, bundle, platform=node)
|
||
├── .vscodeignore
|
||
├── README.md
|
||
├── LICENSE
|
||
├── media/
|
||
│ └── icon.png # extension icon (16x16/128x128)
|
||
├── src/
|
||
│ ├── extension.ts # activate(), command registration, status bar
|
||
│ ├── config.ts # typed wrapper over vscode.workspace.getConfiguration
|
||
│ ├── state.ts # workspace-state published_at map + session key holder
|
||
│ ├── frontmatter.ts # YAML front-matter parser + stripper + validator
|
||
│ ├── nostr/
|
||
│ │ ├── nip23.ts # build kind 30023 event from front-matter + body
|
||
│ │ ├── relay.ts # WebSocket publish to N relays, collect OK/NOTICE
|
||
│ │ └── npub.ts # hex <-> npub helpers (via nostr-tools nip19)
|
||
│ ├── signer/
|
||
│ │ ├── localSigner.ts # Phase 1: thin wrapper over nostr-tools PlainKeySigner
|
||
│ │ └── nsignerBackend.ts # Phase 2: implements nostr-tools Signer via unix-abstract JSON-RPC
|
||
│ ├── nsigner/
|
||
│ │ ├── client.ts # 4-byte BE length-framed JSON-RPC over net.createConnection
|
||
│ │ └── discovery.ts # parse /proc/net/unix for @nsigner* sockets
|
||
│ └── ui/
|
||
│ ├── publishDialog.ts # multi-step QuickPick: preview, overrides, relays, confirm
|
||
│ └── statusBar.ts # status bar item: backend + npub + relay count
|
||
└── webview/ # (reserved — empty for Phase 1/2; future rich preview)
|
||
```
|
||
|
||
### Why esbuild
|
||
|
||
VSCode recommends esbuild for extension bundling. It produces a single
|
||
`dist/extension.js` with all deps inlined, fast cold-start, and works in both
|
||
VSCode and Codium (which share the extension host runtime). `webpack` is the
|
||
alternative; esbuild is simpler and faster for this scope.
|
||
|
||
---
|
||
|
||
## 2. `package.json` manifest
|
||
|
||
### `engines`
|
||
|
||
```json
|
||
"engines": { "vscode": "^1.85.0" }
|
||
```
|
||
|
||
Codium advertises the same `vscode` engine API; no Codium-specific engine key
|
||
exists. Targeting 1.85 keeps compatibility with current Codium releases while
|
||
allowing modern API (e.g. `window.showInputBox` options, `SecretStorage`).
|
||
|
||
### `activationEvents`
|
||
|
||
```json
|
||
"activationEvents": ["onCommand:nostr.publishLongForm"]
|
||
```
|
||
|
||
Lazy activation — the extension only loads when a Nostr command is first run.
|
||
|
||
### `main`
|
||
|
||
```json
|
||
"main": "./dist/extension.js"
|
||
```
|
||
|
||
### `contributes.commands`
|
||
|
||
| Command ID | Title | When |
|
||
|---|---|---|
|
||
| `nostr.signIn` | Nostr: Sign In | always |
|
||
| `nostr.signOut` | Nostr: Sign Out | `nostr.signedIn` |
|
||
| `nostr.publishLongForm` | Nostr: Publish Current File as Long-Form Note | `editorLangId == markdown` |
|
||
| `nostr.validateFrontMatter` | Nostr: Validate Front-Matter | `editorLangId == markdown` |
|
||
| `nostr.resetPublishedTimestamps` | Nostr: Reset Published Timestamps | always |
|
||
| `nostr.selectBackend` | Nostr: Select Signer Backend | always |
|
||
|
||
### `contributes.configuration`
|
||
|
||
```jsonc
|
||
"nostr.relays": {
|
||
"type": "array",
|
||
"items": { "type": "string" },
|
||
"default": [
|
||
"wss://relay.damus.io",
|
||
"wss://relay.primal.net",
|
||
"wss://laantungir.net/relay"
|
||
],
|
||
"description": "Relays to publish long-form notes to."
|
||
},
|
||
"nostr.signerBackend": {
|
||
"type": "string",
|
||
"enum": ["local", "nsigner"],
|
||
"default": "local",
|
||
"description": "Signing backend. 'local' uses an in-memory key; 'nsigner' delegates to a running n_signer."
|
||
},
|
||
"nostr.nsigner.socketName": {
|
||
"type": "string",
|
||
"default": "nsigner",
|
||
"description": "Abstract socket name (without @) of the running n_signer. Used when signerBackend=nsigner."
|
||
},
|
||
"nostr.nsigner.nostrIndex": {
|
||
"type": "number",
|
||
"default": 0,
|
||
"description": "nostr_index passed to n_signer get_public_key / sign_event."
|
||
},
|
||
"nostr.publish.confirm": {
|
||
"type": "boolean",
|
||
"default": true,
|
||
"description": "Show the publish confirmation dialog before broadcasting."
|
||
}
|
||
```
|
||
|
||
### `contributes.menus` — editor context + command palette
|
||
|
||
- `editor/context` for `nostr.publishLongForm` and `nostr.validateFrontMatter`
|
||
gated on `editorLangId == markdown`.
|
||
- Command palette entries for all commands (default behavior, no extra menu
|
||
block needed).
|
||
|
||
### `contributes.viewsContainers` — none for Phase 1/2
|
||
|
||
A sidebar view is out of scope. The status bar item is the primary identity
|
||
surface. A future phase can add a "Published Notes" tree view.
|
||
|
||
---
|
||
|
||
## 3. `src/extension.ts` — activation entrypoint
|
||
|
||
```ts
|
||
export async function activate(context: vscode.ExtensionContext) {
|
||
const state = new StateService(context);
|
||
const config = new ConfigService();
|
||
const statusBar = new StatusBar(state, config);
|
||
const signer = new SignerFactory(state, config); // returns Local or Nsigner backend
|
||
|
||
registerCommand(context, "nostr.signIn", () => cmdSignIn(state, statusBar));
|
||
registerCommand(context, "nostr.signOut", () => cmdSignOut(state, statusBar));
|
||
registerCommand(context, "nostr.publishLongForm", () => cmdPublish(state, config, signer, statusBar));
|
||
registerCommand(context, "nostr.validateFrontMatter", () => cmdValidate());
|
||
registerCommand(context, "nostr.resetPublishedTimestamps", () => cmdResetTimestamps(state));
|
||
registerCommand(context, "nostr.selectBackend", () => cmdSelectBackend(config, statusBar));
|
||
|
||
statusBar.refresh();
|
||
}
|
||
```
|
||
|
||
`StateService` holds:
|
||
- `secretKey: Uint8Array | null` — in-memory only, wiped on `signOut` and on
|
||
extension deactivate. Never written to `SecretStorage` (per user decision:
|
||
keep it simple, re-enter on reload).
|
||
- `pubkeyHex: string | null` — derived from the key, used for status bar and
|
||
event `pubkey` field.
|
||
- `publishedAtMap: Record<string, number>` — backed by
|
||
`context.workspaceState` under key `nostr.publishedAtMap`.
|
||
|
||
`SignerFactory.getBackend()` reads `nostr.signerBackend` config and returns
|
||
either `LocalSigner` (requires `state.secretKey`) or `NsignerBackend`
|
||
(requires a reachable `n_signer` socket). Throws a typed
|
||
`SignerNotReadyError` if the precondition is unmet; the command handler
|
||
surfaces it as an actionable error message.
|
||
|
||
---
|
||
|
||
## 4. `src/frontmatter.ts` — YAML front-matter parser
|
||
|
||
### Format
|
||
|
||
Leading block delimited by `---` on its own line:
|
||
|
||
```markdown
|
||
---
|
||
slug: my-post
|
||
title: My Post
|
||
summary: Short abstract.
|
||
image: https://cdn.example.com/cover.png
|
||
tags: [nostr, longform, writing]
|
||
---
|
||
```
|
||
|
||
### Parsing algorithm
|
||
|
||
1. Read the active document text.
|
||
2. If the text starts with `---\n`, find the next line that is exactly `---`.
|
||
The text between is the YAML body; everything after the closing `---\n` is
|
||
the content.
|
||
3. Parse the YAML body with `yaml` npm package (small, well-maintained,
|
||
handles inline arrays and scalars). On parse error, throw
|
||
`FrontMatterError` with line info.
|
||
4. Extract known keys: `slug`, `title`, `summary`, `image`, `tags`.
|
||
5. Collect unknown keys into a `warnings: string[]` list (non-blocking).
|
||
6. Apply defaults and sanitization (see
|
||
[`longform-metadata-strategy.md`](longform-metadata-strategy.md) §"Parsing
|
||
rules"):
|
||
- `slug` default = filename stem; sanitize to `[a-z0-9-]`.
|
||
- `title` default = filename stem.
|
||
- `summary` default = first non-empty non-heading line, truncated 280.
|
||
- `tags` accept array or comma string; lowercase, trim, strip `#`.
|
||
7. Return `{ frontMatter, content, warnings }`.
|
||
|
||
### Stripping
|
||
|
||
`content` (the return value above) already excludes the front-matter block.
|
||
The publish flow uses `content` as the event `content` field. The on-disk file
|
||
is never modified.
|
||
|
||
### `nostr.validateFrontMatter` command
|
||
|
||
Calls the parser on the active document and shows an
|
||
`vscode.window.showInformationMessage` with:
|
||
- Emitted tags: `d=...`, `title=...`, `summary=...`, `image=...`,
|
||
`t=[...]`, `published_at=<new|existing ts>`.
|
||
- Warnings (unknown keys) if any.
|
||
- Body length in chars.
|
||
|
||
Non-blocking — no publish happens.
|
||
|
||
---
|
||
|
||
## 5. `src/nostr/nip23.ts` — event builder
|
||
|
||
```ts
|
||
import { LongFormArticle } from "nostr-tools/kinds";
|
||
import type { EventTemplate } from "nostr-tools/core";
|
||
|
||
export interface LongFormInput {
|
||
slug: string; // d tag
|
||
title: string;
|
||
summary: string;
|
||
image?: string;
|
||
tags: string[]; // topic strings, no #
|
||
content: string; // body with front-matter stripped
|
||
publishedAt: number; // unix seconds, from state map or now
|
||
}
|
||
|
||
export function buildLongFormEvent(input: LongFormInput): EventTemplate {
|
||
const tags: string[][] = [
|
||
["d", input.slug],
|
||
["title", input.title],
|
||
["summary", input.summary],
|
||
];
|
||
if (input.image) tags.push(["image", input.image]);
|
||
tags.push(["published_at", String(input.publishedAt)]);
|
||
for (const t of input.tags) tags.push(["t", t]);
|
||
tags.push(["alt", `Long-form post: ${input.title}`]);
|
||
|
||
return {
|
||
kind: LongFormArticle, // 30023, from nostr-tools/kinds
|
||
created_at: Math.floor(Date.now() / 1000),
|
||
tags,
|
||
content: input.content,
|
||
};
|
||
}
|
||
```
|
||
|
||
Returns `EventTemplate` (the `nostr-tools` unsigned-event type — no `pubkey`
|
||
or `id` yet; those are added by the signer). The `pubkey` is injected by the
|
||
signer backend from its own `getPublicKey()`, so the builder does not need it.
|
||
This is exactly what `nostr-tools`' `Signer.signEvent(event: EventTemplate)`
|
||
expects.
|
||
|
||
### `published_at` resolution
|
||
|
||
Before building, the publish command consults `state.getPublishedAt(slug)`:
|
||
- If present → use the stored value (this is an update).
|
||
- If absent → use `now`, and after successful publish, call
|
||
`state.setPublishedAt(slug, now)`.
|
||
|
||
This keeps the first-publish timestamp stable across edits, per NIP-23.
|
||
|
||
---
|
||
|
||
## 6. `src/nostr/relay.ts` — relay publisher
|
||
|
||
```ts
|
||
export interface PublishResult {
|
||
relay: string;
|
||
ok: boolean;
|
||
message: string; // OK reason, NOTICE text, or error
|
||
}
|
||
|
||
export async function publishToRelays(
|
||
signedEvent: Event,
|
||
relays: string[],
|
||
timeoutMs = 10000
|
||
): Promise<PublishResult[]>
|
||
```
|
||
|
||
### Implementation
|
||
|
||
1. For each relay URL, open a `WebSocket` (use the `ws` npm package — Node-side
|
||
WebSocket, works in the extension host which is a Node process).
|
||
2. On open, send `["EVENT", signedEvent]`.
|
||
3. Listen for `["OK", eventId, true, "..."]` (success) or
|
||
`["OK", eventId, false, "reason"]` (rejected), or `["NOTICE", "..."]`.
|
||
4. Resolve each relay's `PublishResult` on first terminal message or timeout.
|
||
5. Close all sockets in a `finally` block.
|
||
6. Return the array so the UI can show per-relay outcomes.
|
||
|
||
`ws` is added as a dependency. `nostr-tools` also depends on `ws` for Node
|
||
use, so this is consistent. esbuild bundles both into `dist/extension.js`.
|
||
|
||
---
|
||
|
||
## 7. Signer abstraction — reuse `nostr-tools`' `Signer` interface
|
||
|
||
The fork ships a `Signer` interface in
|
||
[`nostr-tools/signer.ts`](../nostr-tools/signer.ts:4):
|
||
|
||
```ts
|
||
// from nostr-tools/signer.ts (do not redefine)
|
||
export interface Signer {
|
||
getPublicKey(): Promise<string>
|
||
signEvent(event: EventTemplate): Promise<VerifiedEvent>
|
||
}
|
||
```
|
||
|
||
The extension does **not** redefine this. Both backends implement
|
||
`nostr-tools`' `Signer`. The extension adds a small extension interface for
|
||
UI/preflight concerns only:
|
||
|
||
```ts
|
||
// src/signer/backend.ts
|
||
import type { Signer } from "nostr-tools/signer";
|
||
|
||
export interface NostrSigner extends Signer {
|
||
readonly kind: "local" | "nsigner";
|
||
ready(): Promise<boolean>; // preflight: key loaded / n_signer reachable
|
||
describe(): string; // status bar / error hint text
|
||
}
|
||
|
||
export class SignerNotReadyError extends Error {
|
||
constructor(public backend: string, public hint: string) { super(...); }
|
||
}
|
||
```
|
||
|
||
`NostrSigner` extends the upstream `Signer` so any code that accepts a
|
||
`nostr-tools` `Signer` also accepts our backends. The publish command calls
|
||
`backend.ready()` first; on `false` it throws `SignerNotReadyError` and the
|
||
command handler shows `vscode.window.showErrorMessage` with the `hint`
|
||
(e.g. "Run `nsigner --listen unix --socket-name nsigner` and try again").
|
||
|
||
---
|
||
|
||
## 8. `src/signer/localSigner.ts` — Phase 1
|
||
|
||
Thin wrapper over the fork's `PlainKeySigner`
|
||
([`nostr-tools/signer.ts`](../nostr-tools/signer.ts:9)), extended with the
|
||
`NostrSigner` preflight/describe fields:
|
||
|
||
```ts
|
||
import { PlainKeySigner } from "nostr-tools/signer";
|
||
import type { NostrSigner } from "./backend";
|
||
|
||
export class LocalSigner implements NostrSigner {
|
||
readonly kind = "local";
|
||
private inner: PlainKeySigner;
|
||
|
||
constructor(secretKey: Uint8Array) {
|
||
this.inner = new PlainKeySigner(secretKey);
|
||
}
|
||
|
||
getPublicKey() { return this.inner.getPublicKey(); }
|
||
signEvent(event) { return this.inner.signEvent(event); }
|
||
async ready() { return this.inner !== null; } // key present in-memory
|
||
describe() { return "local in-memory key"; }
|
||
}
|
||
```
|
||
|
||
`PlainKeySigner` already calls `finalizeEvent` (which calls `getEventHash` +
|
||
`schnorr.sign`) — no need to reimplement. The secret key is held by
|
||
`StateService` and zeroed on sign-out / deactivate.
|
||
|
||
### `nostr.signIn` command
|
||
|
||
1. `vscode.window.showInputBox({ prompt: "Enter nsec1... or hex secret key",
|
||
password: true })`.
|
||
2. Normalize: if starts with `nsec1`, decode via `nip19.nsecDecode`; else
|
||
parse as 64-char hex.
|
||
3. Validate length === 32 bytes; on failure show error and abort.
|
||
4. Derive pubkey via `schnorr.getPublicKey`.
|
||
5. Store key + pubkey in `StateService` (in-memory).
|
||
6. Refresh status bar; show info message with the npub.
|
||
|
||
### `nostr.signOut` command
|
||
|
||
Zero the key buffer (`key.fill(0)`), clear `pubkeyHex`, refresh status bar.
|
||
|
||
---
|
||
|
||
## 9. `src/signer/nsignerBackend.ts` + `src/nsigner/client.ts` — Phase 2
|
||
|
||
### `nsigner/client.ts` — wire client
|
||
|
||
Direct port of the TypeScript reference in
|
||
[`n_signer/documents/CLIENT_IMPLEMENTATION.md`](../n_signer/documents/CLIENT_IMPLEMENTATION.md:345)
|
||
section 9.3, adapted to a class with a configurable socket name:
|
||
|
||
```ts
|
||
import net from "node:net";
|
||
|
||
export class NsignerClient {
|
||
constructor(private socketName: string) {} // without leading @
|
||
|
||
async call(method: string, params: unknown[]): Promise<unknown> {
|
||
const request = { id: "1", method, params };
|
||
const payload = Buffer.from(JSON.stringify(request), "utf8");
|
||
const header = Buffer.alloc(4);
|
||
header.writeUInt32BE(payload.length, 0);
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const socket = net.createConnection({ path: `\u0000${this.socketName}` });
|
||
let chunks: Buffer[] = [];
|
||
let needed = 4;
|
||
let mode: "header" | "body" = "header";
|
||
const timer = setTimeout(() => { socket.destroy(); reject(new Error("n_signer timeout")); }, 30000);
|
||
|
||
socket.on("connect", () => socket.write(Buffer.concat([header, payload])));
|
||
socket.on("data", (data) => {
|
||
chunks.push(data);
|
||
let buf = Buffer.concat(chunks);
|
||
while (buf.length >= needed) {
|
||
const part = buf.subarray(0, needed);
|
||
buf = buf.subarray(needed);
|
||
if (mode === "header") { needed = part.readUInt32BE(0); mode = "body"; }
|
||
else { clearTimeout(timer); socket.end(); resolve(JSON.parse(part.toString("utf8"))); return; }
|
||
}
|
||
chunks = [buf];
|
||
});
|
||
socket.on("error", (e) => { clearTimeout(timer); reject(e); });
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
The `\u0000` prefix is the Linux abstract-namespace marker — this is the key
|
||
detail that makes it connect to `@nsigner` rather than a filesystem path.
|
||
|
||
### `nsigner/discovery.ts`
|
||
|
||
```ts
|
||
export async function discoverNsignerSockets(): Promise<string[]> {
|
||
const content = await fs.promises.readFile("/proc/net/unix", "utf8");
|
||
const sockets: string[] = [];
|
||
for (const line of content.split("\n")) {
|
||
// abstract socket paths appear with a leading \0 in /proc/net/unix
|
||
const m = line.match(/\x00nsigner[^\s]*/);
|
||
if (m) sockets.push(m[0].slice(1)); // strip the \0, return without @
|
||
}
|
||
return [...new Set(sockets)];
|
||
}
|
||
```
|
||
|
||
Used by `nostr.selectBackend` when the user picks `nsigner`: if the configured
|
||
`nostr.nsigner.socketName` is not found in `/proc/net/unix`, list discovered
|
||
sockets and let the user pick one.
|
||
|
||
### `nsignerBackend.ts`
|
||
|
||
Implements `nostr-tools`' `Signer` (via our `NostrSigner` extension). The
|
||
`signEvent` signature matches `Signer.signEvent(event: EventTemplate)` — it
|
||
receives an unsigned template and returns a verified event with `id`, `pubkey`,
|
||
`sig` filled in by n_signer.
|
||
|
||
```ts
|
||
import type { EventTemplate, VerifiedEvent } from "nostr-tools/core";
|
||
import type { NostrSigner } from "./backend";
|
||
import { NsignerClient } from "../nsigner/client";
|
||
|
||
export class NsignerBackend implements NostrSigner {
|
||
readonly kind = "nsigner";
|
||
private client: NsignerClient;
|
||
private nostrIndex: number;
|
||
|
||
constructor(socketName: string, nostrIndex: number) {
|
||
this.client = new NsignerClient(socketName);
|
||
this.nostrIndex = nostrIndex;
|
||
}
|
||
|
||
async ready(): Promise<boolean> {
|
||
try { await this.getPublicKey(); return true; } catch { return false; }
|
||
}
|
||
|
||
async getPublicKey(): Promise<string> {
|
||
const res = await this.client.call("get_public_key", [{ nostr_index: this.nostrIndex }]);
|
||
const r = res as { result?: string; error?: { message: string } };
|
||
if (r.error) throw new Error(`n_signer: ${r.error.message}`);
|
||
return r.result!;
|
||
}
|
||
|
||
async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
|
||
// n_signer expects the event JSON as a string in params[0]; it fills in
|
||
// pubkey, id, sig and returns the signed event as a JSON string.
|
||
const res = await this.client.call("sign_event", [
|
||
JSON.stringify(event),
|
||
{ nostr_index: this.nostrIndex },
|
||
]);
|
||
const r = res as { result?: string; error?: { message: string } };
|
||
if (r.error) throw new Error(`n_signer: ${r.error.message}`);
|
||
return JSON.parse(r.result!) as VerifiedEvent;
|
||
}
|
||
|
||
describe() { return `n_signer @${this.client["socketName"]} idx=${this.nostrIndex}`; }
|
||
}
|
||
```
|
||
|
||
Wire contract confirmed against
|
||
[`n_signer/client/demo_javascript.js`](../n_signer/client/demo_javascript.js:99)
|
||
(`get_public_key` params `[{nostr_index}]`) and
|
||
[`n_signer/client/README.md`](../n_signer/client/README.md:50) (`sign_event`
|
||
params `[eventJson, {nostr_index|role}]`). No auth envelope needed for unix
|
||
abstract sockets — identity is UID-based via `SO_PEERCRED` (confirmed in
|
||
[`n_signer/documents/SECURITY.md`](../n_signer/documents/SECURITY.md:428)).
|
||
|
||
---
|
||
|
||
## 10. `src/ui/publishDialog.ts` — publish confirmation
|
||
|
||
Multi-step `vscode.window.showQuickPick` flow (per user decision: start with
|
||
QuickPick, reserve Webview for a future phase). Steps:
|
||
|
||
1. **Preview step** — show a single QuickPick with one item per field, each
|
||
item's `label` is the field name and `description` is the resolved value:
|
||
```
|
||
d: my-post
|
||
title: My Post
|
||
summary: Short abstract.
|
||
image: https://cdn.example.com/cover.png
|
||
tags: nostr, longform, writing
|
||
published_at: NEW (will be set to now)
|
||
body: 1234 chars
|
||
signer: local in-memory key
|
||
pubkey: npub1...
|
||
relays: damus, primal, laantungir
|
||
```
|
||
Items are non-selectable (`canPickMany=false`, and the user picks a final
|
||
action item at the bottom): `Publish`, `Edit field...`, `Cancel`.
|
||
|
||
2. **Edit field step** — if the user picks `Edit field...`, show a QuickPick
|
||
of editable fields (`d`, `title`, `summary`, `image`, `tags`), then an
|
||
`showInputBox` pre-filled with the current value. The override is applied
|
||
to an in-memory copy (not written back to the file). Loop back to preview.
|
||
|
||
3. **Relay toggle step** — `canPickMany=true` QuickPick of configured relays,
|
||
all checked by default. User can uncheck relays for this publish only.
|
||
|
||
4. **Publish** — builds the event with (possibly overridden) fields, signs via
|
||
the active backend, publishes to the selected relays, stores
|
||
`published_at` if first publish, shows a summary message with per-relay
|
||
OK/failed counts and the note's `nevent1`/`naddr1` identifier.
|
||
|
||
If `nostr.publish.confirm` is `false`, skip the dialog and publish
|
||
immediately with front-matter values and all configured relays.
|
||
|
||
---
|
||
|
||
## 11. `src/ui/statusBar.ts` — status bar item
|
||
|
||
A `vscode.window.createStatusBarItem` on the left side, priority 100. Text:
|
||
|
||
- Signed out: `$(nostr) Nostr: signed out`
|
||
- Local signed in: `$(nostr) npub1…abc local`
|
||
- n_signer connected: `$(nostr) npub1…abc nsigner`
|
||
- n_signer unreachable: `$(nostr) nsigner: unreachable`
|
||
|
||
Clicking runs `nostr.selectBackend`. Refreshed on sign-in/out, backend
|
||
switch, and on a 5s interval timer (to catch n_signer going up/down).
|
||
|
||
---
|
||
|
||
## 12. Dependencies (`package.json`)
|
||
|
||
The extension depends on the **local laantungir fork** of `nostr-tools`
|
||
(cloned at `~/lt/nostr-tools`, remote
|
||
`git@laantungir.net:laantungir/nostr-tools.git`, currently tracking upstream
|
||
v2.23.3). Use a `file:` dependency so the extension builds against the local
|
||
checkout without publishing to a registry:
|
||
|
||
```json
|
||
"dependencies": {
|
||
"nostr-tools": "file:../nostr-tools",
|
||
"ws": "^8.16.0",
|
||
"yaml": "^2.3.4"
|
||
},
|
||
"devDependencies": {
|
||
"@types/vscode": "^1.85.0",
|
||
"@types/node": "^20.10.0",
|
||
"@types/ws": "^8.5.10",
|
||
"typescript": "^5.3.0",
|
||
"esbuild": "^0.19.0",
|
||
"@vscode/vsce": "^2.22.0"
|
||
}
|
||
```
|
||
|
||
The fork is ESM (`"type": "module"`) but ships a CJS build at
|
||
`lib/cjs/index.js` (see [`nostr-tools/package.json`](../nostr-tools/package.json:15)).
|
||
esbuild resolves the `require` export and bundles it into `dist/extension.js`
|
||
for the Node-based extension host. `@noble/secp256k1` and `@noble/hashes`
|
||
come transitively via `nostr-tools`.
|
||
|
||
### Why the fork over upstream npm
|
||
|
||
- **Newer version** (2.23.3 vs upstream npm's 2.5.0) — bug fixes and NIP
|
||
coverage.
|
||
- **`Signer` interface + `PlainKeySigner`** in
|
||
[`nostr-tools/signer.ts`](../nostr-tools/signer.ts:4) — the extension
|
||
reuses this interface directly instead of inventing its own.
|
||
- **`LongFormArticle = 30023`** constant in
|
||
[`nostr-tools/kinds.ts`](../nostr-tools/kinds.ts:190) — replaces a
|
||
hardcoded magic number.
|
||
- **`nip06`** module available if a future phase adds mnemonic-derived keys
|
||
(matches n_signer's derivation).
|
||
- **Self-hosted source of truth** — the laantungir git server is the canonical
|
||
remote for this ecosystem; depending on it keeps the extension aligned with
|
||
any local patches that may land in the fork.
|
||
|
||
### Build prerequisite
|
||
|
||
`nostr-tools` must be built before the extension can compile it:
|
||
|
||
```bash
|
||
cd ../nostr-tools && npm install && npm run build
|
||
```
|
||
|
||
This produces `lib/cjs/*.js` and `lib/types/*.d.ts`. The extension's esbuild
|
||
step then bundles from there. Documented in the extension README.
|
||
|
||
### `esbuild.mjs`
|
||
|
||
```js
|
||
import esbuild from "esbuild";
|
||
esbuild.build({
|
||
entryPoints: ["src/extension.ts"],
|
||
bundle: true,
|
||
platform: "node",
|
||
format: "cjs",
|
||
target: "node18",
|
||
outfile: "dist/extension.js",
|
||
external: ["vscode"], // always external
|
||
logLevel: "info",
|
||
});
|
||
```
|
||
|
||
### `tsconfig.json`
|
||
|
||
Standard strict TS config, `module: "commonjs"`, `target: "ES2022"`,
|
||
`lib: ["ES2022"]`, `skipLibCheck: true`, `outDir: "dist"`.
|
||
|
||
### `.vscodeignore`
|
||
|
||
```
|
||
.vscode/**
|
||
src/**
|
||
node_modules/**
|
||
esbuild.mjs
|
||
tsconfig.json
|
||
*.map
|
||
```
|
||
|
||
Keep `dist/extension.js`, `package.json`, `README.md`, `LICENSE`, `media/`.
|
||
|
||
---
|
||
|
||
## 13. Build & package
|
||
|
||
```bash
|
||
npm install
|
||
npm run compile # node esbuild.mjs
|
||
npm run package # vsce package -> nostr-publish-0.1.0.vsix
|
||
```
|
||
|
||
`package.json` scripts:
|
||
|
||
```json
|
||
"scripts": {
|
||
"compile": "node esbuild.mjs",
|
||
"watch": "node esbuild.mjs --watch",
|
||
"package": "vsce package",
|
||
"publish": "vsce publish"
|
||
}
|
||
```
|
||
|
||
### F5 verification
|
||
|
||
`.vscode/launch.json` with a single "Run Extension" config launching the
|
||
Extension Development Host with the workspace folder. Verify:
|
||
|
||
1. F5 launches a new Codium window with the extension loaded.
|
||
2. `Nostr: Sign In` accepts an nsec and the status bar updates.
|
||
3. Open a `.md` file with front-matter, run `Nostr: Validate Front-Matter`,
|
||
see the parsed tags.
|
||
4. Run `Nostr: Publish Current File as Long-Form Note`, see the dialog, pick
|
||
Publish, see per-relay results.
|
||
5. Start `nsigner --listen unix --socket-name nsigner` in a terminal, run
|
||
`Nostr: Select Signer Backend`, pick `nsigner`, see the status bar switch
|
||
and a publish go through n_signer (approval prompt appears in the n_signer
|
||
terminal).
|
||
|
||
---
|
||
|
||
## 14. Implementation order (matches the todo list)
|
||
|
||
1. Scaffold project: `package.json`, `tsconfig.json`, `esbuild.mjs`,
|
||
`.vscodeignore`, `README.md` stub, `LICENSE`, `media/icon.png`.
|
||
2. `src/config.ts`, `src/state.ts` — config + state plumbing.
|
||
3. `src/nostr/npub.ts`, `src/nostr/nip23.ts`, `src/nostr/relay.ts` — core
|
||
nostr layer.
|
||
4. `src/frontmatter.ts` — parser + validator.
|
||
5. `src/signer/backend.ts`, `src/signer/localSigner.ts` — Phase 1 signer.
|
||
6. `src/ui/statusBar.ts`, `src/extension.ts` — wire commands + status bar.
|
||
7. `src/ui/publishDialog.ts` — publish confirmation flow.
|
||
8. `src/nsigner/client.ts`, `src/nsigner/discovery.ts`,
|
||
`src/signer/nsignerBackend.ts` — Phase 2 signer.
|
||
9. `nostr.selectBackend` command + `SignerFactory` wiring.
|
||
10. README full content (front-matter format, Phase 1, Phase 2, limitations).
|
||
11. `npm install`, `npm run compile`, `vsce package`, F5 verify.
|
||
|
||
Each step is independently testable: steps 1–7 deliver a working Phase 1;
|
||
steps 8–9 add Phase 2; steps 10–11 close out.
|
||
|
||
---
|
||
|
||
## 15. Out of scope (future phases)
|
||
|
||
- **Image upload** (Blossom / NIP-96) — local `` paths are left
|
||
as-is in Phase 1/2; documented as a known limitation. Future phase rewrites
|
||
them to uploaded URLs using `~/lt/blossom`.
|
||
- **Webview publish dialog** — richer preview with rendered markdown. QuickPick
|
||
is the Phase 1/2 UI.
|
||
- **Sidebar "Published Notes" tree view** — list past publishes from a local
|
||
cache or relay fetch.
|
||
- **NIP-19 `naddr1` sharing** — generate a shareable address after publish
|
||
(shown in the success message; full sharing UI deferred).
|
||
- **Key persistence via `SecretStorage`** — explicitly deferred per user
|
||
decision ("keep it simple, quickly go to Phase 2").
|