Files
codium_nostr_extension/plans/implementation-plan.md
T
2026-07-18 17:29:20 -04:00

757 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `![](./x.png)` 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").