diff --git a/README.md b/README.md index 14b3ce1..821d1a8 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,19 @@ by a local-first vault on the device filesystem. Implements the architecture in `docs/specs/mobile/` (Phase 0 + the on-device parts of Phase 1). The shell consumes immutable, compiled `@zennotes/app-core`, -`@zennotes/bridge-contract`, and `@zennotes/shared-domain` archives. The current -local candidates live in `vendor/zennotes`; its manifest records their source -identity and checksums. A clean checkout installs them with `npm ci`, without a -source clone or sibling repository. They have not been published. +`@zennotes/bridge-contract`, and `@zennotes/shared-domain` archives. The exact +archives are vendored under `vendor/zennotes/` with their source identity and +checksums (`manifest.json`), and `package-lock.json` pins the complete install. +A clean checkout installs them with `npm ci`, without a source clone or sibling +repository. The vendored set is the core release +[core-2.54.1-core.h75d82a521571dc20](https://github.com/ZenNotes/zennotes/releases/tag/core-2.54.1-core.h75d82a521571dc20) +(desktop commit `0285443b`, clean tree). `npm run boundaries:check` verifies the pins, installed versions, singleton -React/CodeMirror peers, and public export usage. Native storage, iCloud, stable -vault identities, keyboard behavior, sync, and preferences remain in this repo. +React/CodeMirror peers, and public export usage. `npm run core:adopt -- ` +swaps in a new release (see "Adopting a newer core" below). Native storage, +iCloud, stable vault identities, keyboard behavior, sync, and preferences remain +in this repo. ## Architecture @@ -93,10 +98,21 @@ Dev loop against a browser (no simulator): `npm run dev` — note Capacitor plugins are absent in a plain browser, so vault I/O won't work; use the simulator for real testing. -To adopt a newer core, copy the reviewed package archives and portable manifest -into `vendor/zennotes`, update the three exact dependencies, and refresh the lockfile. -Run the boundary check, tests, typecheck, and native build before changing the pin. -Retain the previous artifacts for rollback. Never resolve a mutable branch at build time. +## Adopting a newer core + +`npm run core:adopt -- --source ` downloads a core +release from the desktop repo (`core-X.Y.Z-core.h…`, drafts work with an +authenticated `gh`), verifies every archive's SHA-256 and SHA-512 against its +provenance, the packed package versions, that all three archives record the +same clean source commit, then swaps the archives into `vendor/zennotes`, +rewrites `manifest.json`, the three `file:` dependencies, and the sentence above, +runs `npm install`, and runs the boundary check. Add `--dry-run` to verify and +print the plan without writing anything, or `--from ` for a local +`prepare-boundary-release.mjs core` build. The script runs no git command: +review the diff, then stage `package.json`, `package-lock.json`, `vendor/zennotes`, +and `README.md` by path. Run `npm run upstream`, `npm test`, `npm run sync`, and +a simulator pass before changing the pin. Never resolve a mutable branch at +build time. ## What works today (verified on the iPhone 17 Pro simulator) diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 1c1604e..b16d62b 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 283DF466CD667CDB34C0DE25 /* DeepLinkUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC30A5CDEA5991955FBC0360 /* DeepLinkUITests.swift */; }; 283DF467CD667CDB34C0DE25 /* FavoriteUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC30A5CEEA5991955FBC0360 /* FavoriteUITests.swift */; }; 283DF468CD667CDB34C0DE25 /* MoveDirectoryUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC30A5CFEA5991955FBC0360 /* MoveDirectoryUITests.swift */; }; + 283DF469CD667CDB34C0DE25 /* SearchCreateUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC30A5D0EA5991955FBC0360 /* SearchCreateUITests.swift */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 3322BE59AC62263A587F4D6B /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 85847153B31EF561562A9281 /* SwiftUI.framework */; }; 4444E1477FBE380A04E0BFE0 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A7702CA7CC6312CAD599CD7 /* Foundation.framework */; }; @@ -120,6 +121,7 @@ CC30A5CDEA5991955FBC0360 /* DeepLinkUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeepLinkUITests.swift; sourceTree = ""; }; CC30A5CEEA5991955FBC0360 /* FavoriteUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FavoriteUITests.swift; sourceTree = ""; }; CC30A5CFEA5991955FBC0360 /* MoveDirectoryUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MoveDirectoryUITests.swift; sourceTree = ""; }; + CC30A5D0EA5991955FBC0360 /* SearchCreateUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchCreateUITests.swift; sourceTree = ""; }; D61B685B388CE720F2588065 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; D956FF4D03CEB97B048B0860 /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; DB10756BA75711FC150E34FD /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -264,6 +266,7 @@ CC30A5CDEA5991955FBC0360 /* DeepLinkUITests.swift */, CC30A5CEEA5991955FBC0360 /* FavoriteUITests.swift */, CC30A5CFEA5991955FBC0360 /* MoveDirectoryUITests.swift */, + CC30A5D0EA5991955FBC0360 /* SearchCreateUITests.swift */, ); name = AppUITests; path = AppUITests; @@ -523,6 +526,7 @@ 283DF466CD667CDB34C0DE25 /* DeepLinkUITests.swift in Sources */, 283DF467CD667CDB34C0DE25 /* FavoriteUITests.swift in Sources */, 283DF468CD667CDB34C0DE25 /* MoveDirectoryUITests.swift in Sources */, + 283DF469CD667CDB34C0DE25 /* SearchCreateUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -592,12 +596,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 25; + CURRENT_PROJECT_VERSION = 26; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.11.0; + MARKETING_VERSION = 1.12.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -721,12 +725,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 25; + CURRENT_PROJECT_VERSION = 26; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.11.0; + MARKETING_VERSION = 1.12.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -743,12 +747,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 25; + CURRENT_PROJECT_VERSION = 26; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.11.0; + MARKETING_VERSION = 1.12.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -763,12 +767,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 25; + CURRENT_PROJECT_VERSION = 26; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.11.0; + MARKETING_VERSION = 1.12.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -785,12 +789,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 25; + CURRENT_PROJECT_VERSION = 26; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.11.0; + MARKETING_VERSION = 1.12.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -823,12 +827,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 25; + CURRENT_PROJECT_VERSION = 26; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.11.0; + MARKETING_VERSION = 1.12.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/ios/App/AppUITests/SearchCreateUITests.swift b/ios/App/AppUITests/SearchCreateUITests.swift new file mode 100644 index 0000000..ef96e76 --- /dev/null +++ b/ios/App/AppUITests/SearchCreateUITests.swift @@ -0,0 +1,207 @@ +import XCTest + +/// Core 2.54.0 gives note search a Create row for a name that matches no +/// note, and Enter or a tap on it swaps the results for a New note form +/// inside the same palette (zennotes#826). On the phone the palette is a +/// full-screen takeover with a shell-injected Cancel at its top right +/// (mobile.css), the corner where the form's header puts its destination +/// label ("in Inbox"). Until 1.12.0 the two overlapped: Cancel was drawn +/// across the faded label. The header now ends before Cancel begins. +/// +/// The test opens the form for a name no vault holds, requires the label +/// and Cancel to occupy separate space, and leaves through Cancel, so no +/// note is written to the simulator's vault. +final class SearchCreateUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testNewNoteFormHeaderLeavesRoomForCancel() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) + guard app.windows.firstMatch.frame.width < 768 else { + throw XCTSkip("phone shell only: the iPad shows the desktop palette card, which has no injected Cancel") + } + + let openMenu = app.buttons["Open menu"] + XCTAssertTrue(openMenu.waitForExistence(timeout: 10)) + openMenu.tap() + try hittable(label: "Search", in: app, failure: "the ensō menu did not open").tap() + + let search = app.textFields + .matching(NSPredicate(format: "placeholderValue BEGINSWITH 'Search notes'")) + .firstMatch + waitUntilHittable(search, failure: "the search palette did not open") + search.tap() + let name = "Create row check \(Int(Date().timeIntervalSince1970) % 1_000_000)" + app.typeText(name) + + let createRow = try XCTUnwrap( + hittableMatch(NSPredicate(format: "label BEGINSWITH %@", "Create \"\(name)\""), in: app), + "no Create row for a name that matches no note" + ) + createRow.tap() + + let heading = app.staticTexts["New note"] + XCTAssertTrue(heading.waitForExistence(timeout: 5), "the New note form did not open") + let cancel = try button(label: "Cancel", in: app, failure: "the shell did not inject Cancel into the form") + // The label reads "in " and sits on the heading's row; the + // status line under the fields also names the folder, but as part + // of a longer sentence, so the prefix and the row tell them apart. + let destination = try XCTUnwrap( + staticText(prefix: "in ", onRowOf: heading, in: app), + "the form header names no destination" + ) + attachScreenshot("search-create-form", of: app) + + let labelFrame = destination.frame + let cancelFrame = cancel.frame + XCTAssertFalse( + labelFrame.intersects(cancelFrame), + "the destination label \(labelFrame) runs under Cancel \(cancelFrame)" + ) + XCTAssertLessThanOrEqual( + labelFrame.maxX, cancelFrame.minX, + "the destination label \(labelFrame) ends after Cancel \(cancelFrame) begins" + ) + + // Focus returns to the editor with the keyboard still up, and the + // ensō hides under a raised keyboard, so the form's own elements are + // the honest signal that the palette closed, not "Open menu". + cancel.tap() + waitUntilGone(heading, failure: "Cancel did not close the palette: the New note heading is still there") + XCTAssertFalse(cancel.exists, "Cancel outlived the palette it belongs to") + } + + /// Core 2.54.0 lost the first tap on Create while a tag was still typed + /// in the Tags field: the button took focus, the field blurred and + /// committed the word as a chip, the suggestion row under the fields + /// went away and the footer moved before the tap completed (found on + /// this shell, fixed in core 2.54.1). One tap must create the note. + /// This test writes one note into the simulator's vault. + func testFirstTapOnCreateLandsWhileATagIsTyped() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) + + let openMenu = app.buttons["Open menu"] + XCTAssertTrue(openMenu.waitForExistence(timeout: 10)) + openMenu.tap() + try hittable(label: "Search", in: app, failure: "the ensō menu did not open").tap() + + let search = app.textFields + .matching(NSPredicate(format: "placeholderValue BEGINSWITH 'Search notes'")) + .firstMatch + waitUntilHittable(search, failure: "the search palette did not open") + search.tap() + // The suffix keeps the name and the tag new on every run: the note + // stays in the simulator's vault, and a tag it already holds would be + // offered as an existing tag rather than a new one. + let suffix = Int(Date().timeIntervalSince1970) % 1_000_000 + let name = "Tag tap check \(suffix)" + let tag = "tap\(suffix)" + app.typeText(name) + try XCTUnwrap( + hittableMatch(NSPredicate(format: "label BEGINSWITH %@", "Create \"\(name)\""), in: app), + "no Create row for a name that matches no note" + ).tap() + + let heading = app.staticTexts["New note"] + XCTAssertTrue(heading.waitForExistence(timeout: 5), "the New note form did not open") + let tags = app.textFields + .matching(NSPredicate(format: "placeholderValue BEGINSWITH 'Add tags'")) + .firstMatch + waitUntilHittable(tags, failure: "the form has no Tags field") + tags.tap() + app.typeText(tag) + // The word is still text, not a chip: the row offering it as a new + // tag is what the blur used to take away. + XCTAssertTrue( + app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Add #\(tag)")).firstMatch + .waitForExistence(timeout: 5), + "typing in Tags did not offer the word as a new tag" + ) + + let create = try button(label: "Create", in: app, failure: "the form's Create button is not on screen") + attachScreenshot("search-create-typed-tag", of: app) + create.tap() + waitUntilGone(heading, failure: "one tap on Create left the form open: the typed tag took the tap") + XCTAssertTrue( + app.staticTexts.matching(NSPredicate(format: "label CONTAINS %@", "#\(tag)")).firstMatch + .waitForExistence(timeout: 5), + "the created note does not show the #\(tag) tag" + ) + attachScreenshot("search-create-note-open", of: app) + } + + private func waitUntilGone(_ element: XCUIElement, timeout: TimeInterval = 5, failure: String) { + let deadline = Date().addingTimeInterval(timeout) + while element.exists && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + XCTAssertFalse(element.exists, failure) + } + + /// The static text with the given prefix whose vertical centre lies in + /// the row of `anchor`. WebKit exposes the whole document, including + /// text under the palette, so the row is what pins the header's label. + private func staticText(prefix: String, onRowOf anchor: XCUIElement, in app: XCUIApplication) -> XCUIElement? { + let row = anchor.frame + let matches = app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", prefix)) + for index in 0..= row.minY && frame.midY <= row.maxY { + return candidate + } + } + return nil + } + + private func waitUntilHittable(_ element: XCUIElement, timeout: TimeInterval = 10, failure: String) { + let deadline = Date().addingTimeInterval(timeout) + while !element.isHittable && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + XCTAssertTrue(element.isHittable, failure) + } + + /// Kept on success too, so a run's xcresult shows the form on that simulator. + private func attachScreenshot(_ name: String, of app: XCUIApplication) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + private func button(label: String, in app: XCUIApplication, failure: String) throws -> XCUIElement { + try XCTUnwrap(hittableMatch(NSPredicate(format: "label == %@", label), in: app), failure) + } + + /// Any element type: the ensō menu's items are role=menuitem, and the + /// open note can carry the same word earlier in the tree. + private func hittable(label: String, in app: XCUIApplication, failure: String) throws -> XCUIElement { + try XCTUnwrap(hittableMatch(NSPredicate(format: "label == %@", label), in: app, type: .any), failure) + } + + /// Polls for an element that is on screen and tappable: the palette and + /// the form mount a moment after the tap that opens them, and hidden + /// matches (note text under the palette) must not win. + private func hittableMatch( + _ predicate: NSPredicate, + in app: XCUIApplication, + type: XCUIElement.ElementType = .button, + timeout: TimeInterval = 10 + ) -> XCUIElement? { + let deadline = Date().addingTimeInterval(timeout) + repeat { + let matches = app.descendants(matching: type).matching(predicate) + for index in 0.. div:first-child > div:first-child { + padding-right: 4.25rem; +} + +.zn-mobile.zn-phone .z-palette [data-search-create-form] input { + padding-right: 0.625rem; +} + /* Vault text search shows a desktop "VAULT TEXT SEARCH / " caption under the field; on a phone the placeholder already says what it is. */ .zn-mobile.zn-phone .z-palette [role='dialog'] div:has(> input) > div[class*='uppercase'] { diff --git a/tooling/adopt-core.mjs b/tooling/adopt-core.mjs new file mode 100644 index 0000000..4876252 --- /dev/null +++ b/tooling/adopt-core.mjs @@ -0,0 +1,276 @@ +// Adopt a ZenNotes core release into this shell. +// +// npm run core:adopt -- core-2.53.1-core.h0123456789abcdef # a GitHub release (draft or published) +// npm run core:adopt -- --from ../../opensource/zennotes/dist/boundary-release/core-2.53.1-core.h... # a local rehearsal build +// +// The release (or rehearsal directory) holds three archives, one `.tgz.json` +// provenance file per archive, and `release.json`. Nothing is written until +// every archive's SHA-256 and SHA-512 match its provenance, the package inside +// each archive is the one the provenance names, every recorded source commit is +// the same 40-hex commit, and the build came from a clean upstream tree. Then +// the archives replace the ones in `vendor/zennotes/`, `manifest.json` becomes +// the app-core provenance file (as it always has been), the three `file:` +// dependencies in `package.json` point at the new filenames, the README +// sentence that names the vendored release is rewritten, `npm install` pins +// the set in `package-lock.json`, and `check-core-boundary.mjs` runs. +// +// A dirty upstream build is refused unless ZEN_ALLOW_DIRTY_CORE=1, the same +// override `check-core-boundary.mjs` honours for a local try-out. The script +// never runs git: review the diff, then stage `package.json`, +// `package-lock.json`, `vendor/zennotes` and `README.md` by path. + +import assert from 'node:assert/strict' +import { execFileSync, spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { realpathSync } from 'node:fs' +import { copyFile, mkdtemp, readFile, readdir, rm, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' + +export const CORE_PACKAGES = ['@zennotes/app-core', '@zennotes/bridge-contract', '@zennotes/shared-domain'] +export const DEFAULT_REPO = 'ZenNotes/zennotes' +const VENDOR_DIR = 'vendor/zennotes' + +/** Read a candidate directory: release.json plus one provenance file per archive. */ +export async function readCandidate(directory) { + const release = JSON.parse(await readFile(join(directory, 'release.json'), 'utf8')) + assert.equal(release.target, 'core', `release.json target is "${release.target}", expected "core"`) + const listed = (release.files ?? []).filter((file) => file.endsWith('.tgz')) + const present = (await readdir(directory)).filter((file) => file.endsWith('.tgz')).sort() + assert.deepEqual(present, [...listed].sort(), 'archives in the directory differ from release.json "files"') + const entries = [] + for (const file of listed) { + const provenance = JSON.parse(await readFile(join(directory, `${file}.json`), 'utf8')) + entries.push({ file, path: join(directory, file), provenance }) + } + return { directory, release, entries } +} + +/** The version and name of the package inside an npm archive (`package/package.json`). */ +export function packedPackage(archivePath) { + const text = execFileSync('tar', ['-xOf', archivePath, 'package/package.json'], { encoding: 'utf8' }) + const { name, version } = JSON.parse(text) + return { name, version } +} + +/** + * Check a candidate against its own provenance. Returns the app-core + * provenance (the future manifest) when everything agrees; throws otherwise. + */ +export async function verifyCandidate(candidate, { allowDirty = false, expectedSource, expectedTag, readPacked = packedPackage } = {}) { + const { release, entries } = candidate + if (expectedTag) assert.equal(release.tag, expectedTag, 'release.json tag differs from the requested tag') + assert.match(release.sourceCommit ?? '', /^[0-9a-f]{40}$/, 'release.json sourceCommit is not a full commit hash') + if (expectedSource) { + assert.equal(release.sourceCommit, expectedSource.toLowerCase(), 'release.json sourceCommit differs from --source') + } + assert.ok( + release.localCandidate === false || allowDirty, + 'release.json marks this a local candidate (built with --allow-dirty); not shippable. Set ZEN_ALLOW_DIRTY_CORE=1 to try it locally.' + ) + const byName = new Map() + for (const entry of entries) { + const { file, path, provenance } = entry + assert.match(file, /^[a-z0-9.-]+\.tgz$/, `${file}: archive name has characters the boundary check refuses`) + assert.equal(provenance.file, file, `${file}: provenance names a different file (${provenance.file})`) + assert.ok(CORE_PACKAGES.includes(provenance.name), `${file}: unexpected package ${provenance.name}`) + assert.ok(!byName.has(provenance.name), `${provenance.name} appears twice`) + assert.match(provenance.sourceCommit ?? '', /^[0-9a-f]{40}$/, `${provenance.name}: sourceCommit is not a full commit hash`) + assert.equal(provenance.sourceCommit, release.sourceCommit, `${provenance.name}: sourceCommit differs from release.json`) + assert.ok( + provenance.workingTreeDirty === false || allowDirty, + `${provenance.name} was built from a dirty upstream tree (${provenance.sourceCommit.slice(0, 8)}+); not shippable. Set ZEN_ALLOW_DIRTY_CORE=1 to try it locally.` + ) + const bytes = await readFile(path) + assert.equal(createHash('sha256').update(bytes).digest('hex'), provenance.sha256, `${provenance.name}: SHA-256 does not match its provenance`) + assert.equal(`sha512-${createHash('sha512').update(bytes).digest('base64')}`, provenance.integrity, `${provenance.name}: SHA-512 integrity does not match its provenance`) + const packed = readPacked(path) + assert.equal(packed.name, provenance.name, `${file}: archive contains ${packed.name}, provenance says ${provenance.name}`) + assert.equal(packed.version, provenance.version, `${provenance.name}: archive version ${packed.version} differs from provenance ${provenance.version}`) + byName.set(provenance.name, entry) + } + for (const name of CORE_PACKAGES) assert.ok(byName.has(name), `${name} is missing from the candidate`) + const core = byName.get('@zennotes/app-core').provenance + const dependencies = core.dependencies ?? [] + for (const name of CORE_PACKAGES.slice(1)) { + const own = byName.get(name).provenance + const recorded = dependencies.find((dependency) => dependency.name === name) + assert.ok(recorded, `app-core provenance does not list ${name}`) + for (const key of ['version', 'file', 'sha256', 'integrity', 'sourceCommit', 'workingTreeDirty']) { + assert.deepEqual(recorded[key], own[key], `${name}: "${key}" differs between its own provenance and app-core's dependency list`) + } + } + return core +} + +/** Files named by a manifest: the app-core archive and its two dependencies. */ +export function manifestFiles(manifest) { + return [manifest, ...(manifest.dependencies ?? [])].map((entry) => entry.file) +} + +/** Rewrite the three `file:` dependencies in package.json text, keeping everything else byte for byte. */ +export function rewritePackageJson(text, manifest) { + let next = text + for (const entry of [manifest, ...manifest.dependencies]) { + const pattern = new RegExp(`("${entry.name.replace(/[/@.]/g, '\\$&')}":\\s*")file:${VENDOR_DIR}/[^"]+(")`, 'g') + const matches = [...next.matchAll(pattern)] + assert.equal(matches.length, 1, `package.json: expected exactly one file: dependency for ${entry.name}, found ${matches.length}`) + next = next.replace(pattern, `$1file:${VENDOR_DIR}/${entry.file}$2`) + } + return next +} + +const README_PATTERN = /\[core-[^\]]+\]\(https:\/\/github\.com\/[^/]+\/[^/]+\/releases\/tag\/core-[^)]+\)\s*\(desktop commit `[0-9a-f]{7,40}`, clean tree\)/ + +/** Rewrite the README sentence that names the vendored release; returns null when the sentence is not found. */ +export function rewriteReadme(text, { tag, repo, sourceCommit }) { + if (!README_PATTERN.test(text)) return null + const replacement = `[${tag}](https://github.com/${repo}/releases/tag/${tag})\n(desktop commit \`${sourceCommit.slice(0, 8)}\`, clean tree)` + return text.replace(README_PATTERN, replacement) +} + +function run(command, args, cwd) { + const result = spawnSync(command, args, { cwd, stdio: 'inherit' }) + if (result.error) throw result.error + assert.equal(result.status, 0, `${command} ${args.join(' ')} exited with ${result.status}`) +} + +function usage() { + return [ + 'Usage:', + ' node tooling/adopt-core.mjs [--repo owner/name] [--source ] [--dry-run] [--no-install] [--keep-download]', + ' node tooling/adopt-core.mjs --from [--source ] [--dry-run] [--no-install]', + '', + ' GitHub release tag on the desktop repo, e.g. core-2.53.1-core.h0123456789abcdef (drafts work with an authenticated gh)', + ' --from A directory with the same files (a local `prepare-boundary-release.mjs core` build) instead of a download', + ' --source Refuse the candidate unless every recorded source commit equals this one', + ' --dry-run Download and verify, print the plan, change nothing', + ' --no-install Skip npm install and the boundary check after writing files', + ' --keep-download Leave the downloaded files in place and print the directory', + '', + ' ZEN_ALLOW_DIRTY_CORE=1 accepts a build from a dirty upstream tree for a local try-out (never for a release).' + ].join('\n') +} + +export async function main(argv = process.argv.slice(2), env = process.env) { + const { values, positionals } = parseArgs({ + args: argv, + allowPositionals: true, + options: { + repo: { type: 'string', default: DEFAULT_REPO }, + from: { type: 'string' }, + source: { type: 'string' }, + 'dry-run': { type: 'boolean', default: false }, + 'no-install': { type: 'boolean', default: false }, + 'keep-download': { type: 'boolean', default: false }, + help: { type: 'boolean', default: false } + } + }) + if (values.help || (positionals.length === 0 && !values.from)) { + console.log(usage()) + return values.help ? 0 : 2 + } + assert.ok(positionals.length <= 1, 'Expected at most one core tag') + const tag = positionals[0] + assert.ok(!(tag && values.from), 'Give either a core tag or --from, not both') + if (tag) assert.match(tag, /^core-[a-z0-9.-]+$/, `Not a core tag: ${tag}`) + if (values.source) assert.match(values.source, /^[0-9a-f]{40}$/i, '--source must be a full 40-hex commit hash') + const allowDirty = env.ZEN_ALLOW_DIRTY_CORE === '1' + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') + + let directory = values.from ? resolve(values.from) : null + let downloaded = null + if (!directory) { + downloaded = await mkdtemp(join(tmpdir(), 'zennotes-core-')) + console.log(`Downloading ${tag} from ${values.repo} ...`) + run('gh', ['release', 'download', tag, '--repo', values.repo, '-D', downloaded], root) + directory = downloaded + } + + try { + const candidate = await readCandidate(directory) + const manifest = await verifyCandidate(candidate, { allowDirty, expectedSource: values.source, expectedTag: tag }) + const releaseTag = candidate.release.tag + console.log(`Verified ${releaseTag}: source ${manifest.sourceCommit}, ${candidate.entries.length} archives, checksums and packed versions match.`) + if (candidate.release.localCandidate || manifest.workingTreeDirty) { + console.log('WARNING: this is a dirty local candidate, accepted because ZEN_ALLOW_DIRTY_CORE=1. Do not release it.') + } + + const current = JSON.parse(await readFile(join(root, VENDOR_DIR, 'manifest.json'), 'utf8')) + const currentFiles = manifestFiles(current) + const nextFiles = manifestFiles(manifest) + const unchanged = current.version === manifest.version && JSON.stringify(currentFiles) === JSON.stringify(nextFiles) + const removals = currentFiles.filter((file) => !nextFiles.includes(file)) + console.log(unchanged ? `Already on ${manifest.version}; files will be rewritten in place.` : `Replacing ${current.version} with ${manifest.version}.`) + for (const file of nextFiles) console.log(` + ${VENDOR_DIR}/${file}`) + for (const file of removals) console.log(` - ${VENDOR_DIR}/${file}`) + + const packageText = await readFile(join(root, 'package.json'), 'utf8') + const nextPackageText = rewritePackageJson(packageText, manifest) + const readmeText = await readFile(join(root, 'README.md'), 'utf8') + const cleanRelease = !candidate.release.localCandidate && !manifest.workingTreeDirty + const nextReadme = cleanRelease ? rewriteReadme(readmeText, { tag: releaseTag, repo: values.repo, sourceCommit: manifest.sourceCommit }) : readmeText + if (cleanRelease && nextReadme === null) console.log('WARNING: README.md: the "vendored set" sentence was not found; update it by hand.') + + if (values['dry-run']) { + console.log('Dry run: nothing written.') + return 0 + } + + for (const entry of candidate.entries) await copyFile(entry.path, join(root, VENDOR_DIR, entry.file)) + await writeFile(join(root, VENDOR_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n') + for (const file of removals) await unlink(join(root, VENDOR_DIR, file)).catch(() => {}) + await writeFile(join(root, 'package.json'), nextPackageText) + if (nextReadme && nextReadme !== readmeText) await writeFile(join(root, 'README.md'), nextReadme) + console.log(`Wrote ${VENDOR_DIR}/manifest.json, package.json${nextReadme && nextReadme !== readmeText ? ', README.md' : ''}.`) + + if (values['no-install']) { + console.log('Skipped npm install and the boundary check (--no-install).') + } else { + run('npm', ['install', '--no-audit', '--no-fund'], root) + run(process.execPath, [join(root, 'tooling/check-core-boundary.mjs')], root) + } + console.log( + [ + '', + `Adopted ${releaseTag}. Next:`, + ' npm run upstream && npm test && npm run sync', + ' a Debug build from Xcode (ios/App/App.xcworkspace) and a simulator pass', + ` git add package.json package-lock.json ${VENDOR_DIR} README.md (stage by path; the script ran no git command)` + ].join('\n') + ) + return 0 + } catch (error) { + // Keep a failed download so the mismatch can be inspected. + if (downloaded) console.error(`Downloaded files kept for inspection at ${downloaded}`) + downloaded = null + throw error + } finally { + if (downloaded) { + if (values['keep-download']) console.log(`Downloaded files kept at ${downloaded}`) + else await rm(downloaded, { recursive: true, force: true }) + } + } +} + +function invokedDirectly() { + // Compare real paths: Node resolves symlinks for the ESM entry (e.g. /tmp -> /private/tmp on macOS), + // and a mismatch here would turn the whole script into a silent no-op. + try { + return Boolean(process.argv[1]) && realpathSync(resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url)) + } catch { + return false + } +} + +if (invokedDirectly()) { + main().then( + (code) => process.exit(code), + (error) => { + console.error(`\nadopt-core: ${error.message}`) + process.exit(1) + } + ) +} diff --git a/tooling/adopt-core.test.mjs b/tooling/adopt-core.test.mjs new file mode 100644 index 0000000..0dff3fc6 --- /dev/null +++ b/tooling/adopt-core.test.mjs @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { manifestFiles, packedPackage, readCandidate, rewritePackageJson, rewriteReadme, verifyCandidate } from './adopt-core.mjs' + +const SOURCE = '3a622639a245c928f63f1f61d0adfa110e0ca637' +const OTHER_SOURCE = 'f90161fa3d4c4b54629a6cdf9c5cb9329abbefda' + +/** Build a real npm-style archive (package/package.json) and its provenance. */ +async function pack(directory, name, version, { sourceCommit = SOURCE, dirty = false, packedVersion = version } = {}) { + const short = name.replace('@zennotes/', '') + const file = `zennotes-${short}-${version}.tgz` + const stage = join(directory, `stage-${short}`) + await mkdir(join(stage, 'package'), { recursive: true }) + await writeFile(join(stage, 'package/package.json'), JSON.stringify({ name, version: packedVersion }) + '\n') + execFileSync('tar', ['-czf', join(directory, file), '-C', stage, 'package']) + await rm(stage, { recursive: true, force: true }) + const bytes = await readFile(join(directory, file)) + return { + name, + version, + file, + sha256: createHash('sha256').update(bytes).digest('hex'), + integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, + sourceCommit, + workingTreeDirty: dirty + } +} + +/** A complete, consistent candidate directory; `mutate` may edit the provenance objects before they are written. */ +async function candidate(options = {}, mutate = () => {}) { + const directory = await mkdtemp(join(tmpdir(), 'adopt-core-test-')) + const version = options.version ?? '2.53.1' + const bridge = await pack(directory, '@zennotes/bridge-contract', `${version}-boundaries.h1111111111111111`, options.bridge) + const domain = await pack(directory, '@zennotes/shared-domain', `${version}-boundaries.h1111111111111111`, options.domain) + const core = { + ...(await pack(directory, '@zennotes/app-core', `${version}-core.h2222222222222222`, options.core)), + sourceLockSha256: 'd1900218a36064c5a2f613f1db9a6a4895086db6aa30e1cf1b8b35c18753e1c0', + toolchain: { node: 'v22.23.2' }, + dependencies: [structuredClone(bridge), structuredClone(domain)] + } + const release = { + target: 'core', + tag: `core-${core.version}`, + sourceCommit: options.releaseSource ?? SOURCE, + localCandidate: options.localCandidate ?? false, + files: [core.file, `${core.file}.json`, bridge.file, `${bridge.file}.json`, domain.file, `${domain.file}.json`] + } + mutate({ core, bridge, domain, release }) + for (const entry of [core, bridge, domain]) await writeFile(join(directory, `${entry.file}.json`), JSON.stringify(entry, null, 2) + '\n') + await writeFile(join(directory, 'release.json'), JSON.stringify(release, null, 2) + '\n') + return { directory, core, bridge, domain, release } +} + +async function verify(directory, options) { + return verifyCandidate(await readCandidate(directory), options) +} + +test('a consistent candidate verifies and yields the app-core provenance as the manifest', async () => { + const { directory, core } = await candidate() + try { + const manifest = await verify(directory, { expectedTag: `core-${core.version}`, expectedSource: SOURCE }) + assert.equal(manifest.name, '@zennotes/app-core') + assert.equal(manifest.version, core.version) + assert.deepEqual(manifestFiles(manifest), [core.file, ...core.dependencies.map((d) => d.file)]) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('one flipped byte in an archive fails the SHA-256 check before anything else', async () => { + const { directory, bridge } = await candidate() + try { + const bytes = await readFile(join(directory, bridge.file)) + bytes[bytes.length - 1] ^= 0xff // the gzip trailer, so tar itself would still list the entry + await writeFile(join(directory, bridge.file), bytes) + await assert.rejects(verify(directory), /bridge-contract: SHA-256 does not match/) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('a provenance whose sha256 was fixed up but whose integrity was not is still refused', async () => { + const { directory } = await candidate({}, ({ domain, core }) => { + domain.integrity = 'sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==' + core.dependencies[1].integrity = domain.integrity + }) + try { + await assert.rejects(verify(directory), /shared-domain: SHA-512 integrity does not match/) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('archives built from different source commits are refused, even with matching checksums', async () => { + const { directory } = await candidate({ domain: { sourceCommit: OTHER_SOURCE } }, ({ core, domain }) => { + core.dependencies[1].sourceCommit = domain.sourceCommit + }) + try { + await assert.rejects(verify(directory), /shared-domain: sourceCommit differs from release.json/) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('--source must equal the recorded commit; a short or different hash is refused', async () => { + const { directory } = await candidate() + try { + await assert.rejects(verify(directory, { expectedSource: OTHER_SOURCE }), /differs from --source/) + await verify(directory, { expectedSource: SOURCE.toUpperCase() }) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('a dirty build is refused unless allowDirty, for both the release flag and a per-archive flag', async () => { + const local = await candidate({ localCandidate: true }) + const dirtyCore = await candidate({ core: { dirty: true } }) + try { + await assert.rejects(verify(local.directory), /local candidate .* Set ZEN_ALLOW_DIRTY_CORE=1/) + await assert.rejects(verify(dirtyCore.directory), /app-core was built from a dirty upstream tree/) + const manifest = await verify(dirtyCore.directory, { allowDirty: true }) + assert.equal(manifest.workingTreeDirty, true) + await verify(local.directory, { allowDirty: true }) + } finally { + await rm(local.directory, { recursive: true, force: true }) + await rm(dirtyCore.directory, { recursive: true, force: true }) + } +}) + +test('the package inside the archive must be the one the provenance names', async () => { + const wrongVersion = await candidate({ bridge: { packedVersion: '2.53.0-boundaries.h1111111111111111' } }) + try { + await assert.rejects(verify(wrongVersion.directory), /bridge-contract: archive version 2\.53\.0-boundaries\.h1111111111111111 differs from provenance/) + assert.deepEqual(packedPackage(join(wrongVersion.directory, wrongVersion.core.file)), { name: '@zennotes/app-core', version: wrongVersion.core.version }) + } finally { + await rm(wrongVersion.directory, { recursive: true, force: true }) + } +}) + +test("app-core's dependency list must agree with each dependency's own provenance", async () => { + const { directory } = await candidate({}, ({ core }) => { + core.dependencies[0].version = '2.53.1-boundaries.h9999999999999999' + }) + try { + await assert.rejects(verify(directory), /bridge-contract: "version" differs between its own provenance and app-core's dependency list/) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('a missing package, an extra archive, or a wrong tag is refused', async () => { + const missing = await candidate({}, ({ release, domain }) => { + release.files = release.files.filter((file) => !file.startsWith(domain.file)) + }) + const wrongTag = await candidate() + try { + await rm(join(missing.directory, missing.domain.file)) + await rm(join(missing.directory, `${missing.domain.file}.json`)) + await assert.rejects(verify(missing.directory), /@zennotes\/shared-domain is missing/) + await assert.rejects(verify(wrongTag.directory, { expectedTag: 'core-9.9.9-core.h0000000000000000' }), /tag differs from the requested tag/) + await writeFile(join(wrongTag.directory, 'zennotes-extra-1.0.0.tgz'), 'not listed') + await assert.rejects(readCandidate(wrongTag.directory), /differ from release.json "files"/) + } finally { + await rm(missing.directory, { recursive: true, force: true }) + await rm(wrongTag.directory, { recursive: true, force: true }) + } +}) + +const PACKAGE_JSON = `{ + "name": "zennotes-iphone", + "version": "1.1.24", + "dependencies": { + "@capacitor/ios": "^7.0.0", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.53.0-core.h598c8d004c9228a3.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.53.0-boundaries.h193dbe157c4e64d2.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.53.0-boundaries.h193dbe157c4e64d2.tgz", + "react": "^19.2.0" + } +} +` + +test('rewritePackageJson swaps exactly the three file: dependencies and nothing else', () => { + const manifest = { + name: '@zennotes/app-core', + file: 'zennotes-app-core-2.53.1-core.h2222222222222222.tgz', + dependencies: [ + { name: '@zennotes/bridge-contract', file: 'zennotes-bridge-contract-2.53.1-boundaries.h1111111111111111.tgz' }, + { name: '@zennotes/shared-domain', file: 'zennotes-shared-domain-2.53.1-boundaries.h1111111111111111.tgz' } + ] + } + const next = rewritePackageJson(PACKAGE_JSON, manifest) + assert.equal( + next, + PACKAGE_JSON.replace(/2\.53\.0-core\.h598c8d004c9228a3/, '2.53.1-core.h2222222222222222').replaceAll('2.53.0-boundaries.h193dbe157c4e64d2', '2.53.1-boundaries.h1111111111111111') + ) + assert.ok(next.includes('"react": "^19.2.0"')) + assert.throws(() => rewritePackageJson(PACKAGE_JSON.replace(/.*shared-domain.*\n/, ''), manifest), /exactly one file: dependency for @zennotes\/shared-domain, found 0/) +}) + +test('rewriteReadme replaces the tag, link and commit of the vendored-set sentence and reports a missing sentence', () => { + const readme = [ + 'The vendored set is the core release', + '[core-2.53.0-core.h598c8d004c9228a3](https://github.com/ZenNotes/zennotes/releases/tag/core-2.53.0-core.h598c8d004c9228a3)', + '(desktop commit `3a622639`, clean tree). Run `npm run', + 'boundaries:check` to verify.' + ].join('\n') + const next = rewriteReadme(readme, { tag: 'core-2.53.1-core.h2222222222222222', repo: 'ZenNotes/zennotes', sourceCommit: OTHER_SOURCE }) + assert.equal( + next, + [ + 'The vendored set is the core release', + '[core-2.53.1-core.h2222222222222222](https://github.com/ZenNotes/zennotes/releases/tag/core-2.53.1-core.h2222222222222222)', + '(desktop commit `f90161fa`, clean tree). Run `npm run', + 'boundaries:check` to verify.' + ].join('\n') + ) + assert.equal(rewriteReadme('No such sentence here.', { tag: 'core-1', repo: 'a/b', sourceCommit: SOURCE }), null) +}) diff --git a/vendor/zennotes/manifest.json b/vendor/zennotes/manifest.json index 086a27a..65c63c3 100644 --- a/vendor/zennotes/manifest.json +++ b/vendor/zennotes/manifest.json @@ -1,34 +1,34 @@ { "name": "@zennotes/app-core", - "version": "2.53.0-core.h598c8d004c9228a3", - "file": "zennotes-app-core-2.53.0-core.h598c8d004c9228a3.tgz", - "sha256": "ec917fe4198d9da256fed0f60f097dc0e9a1c6cac802031ae7a4f367d0bcf3b0", - "integrity": "sha512-OdDukylM9XIVAQZfjEvxqAbLDTBUd0HpIHggRQsyegFpHTbR5cOL3pTUdmv3k56f3S22SegMRqQCs1BQdoUJSg==", - "sourceCommit": "3a622639a245c928f63f1f61d0adfa110e0ca637", + "version": "2.54.1-core.h75d82a521571dc20", + "file": "zennotes-app-core-2.54.1-core.h75d82a521571dc20.tgz", + "sha256": "480993953be5990c067c652e0d0d09297b633f491eb1200c58999b2944ac77bd", + "integrity": "sha512-T3xDGNIgwCODde4tzVwNyR11vgHx6G6x9IIRlMf+6nbFj1iTaU6iXQIQ+mlB8cSq7qoRF1dURstU4pY+D+ir9w==", + "sourceCommit": "0285443b51889a6034dc24b5b603c0dbab099d4f", "workingTreeDirty": false, - "sourceLockSha256": "d1900218a36064c5a2f613f1db9a6a4895086db6aa30e1cf1b8b35c18753e1c0", + "sourceLockSha256": "37d860dd7c82d029d7bdf932706d16a4996cc81d8d5446fc593ab99f41bd72de", "toolchain": { - "node": "v22.23.1", + "node": "v22.23.2", "typescript": "5.9.3", "tailwind": "3.4.19" }, "dependencies": [ { "name": "@zennotes/bridge-contract", - "version": "2.53.0-boundaries.h193dbe157c4e64d2", - "file": "zennotes-bridge-contract-2.53.0-boundaries.h193dbe157c4e64d2.tgz", - "sha256": "18f746ad9b519868d8c0f2708887d4d22d361fe878ce58cea77e9a5488da0889", - "integrity": "sha512-HO/4BploqBPtJ4Lu4/Wg+YfXuUxHF8RIzqPaA6jAnh20p6zporqtiJbfjQAtu/nCPqeCXgXc3yk9fr/s0ehvKw==", - "sourceCommit": "3a622639a245c928f63f1f61d0adfa110e0ca637", + "version": "2.54.1-boundaries.h1138e41f8e2ad650", + "file": "zennotes-bridge-contract-2.54.1-boundaries.h1138e41f8e2ad650.tgz", + "sha256": "4bb349b0eef15c1cbe8edfe98e926aa97e421c088200f16fa29f9304dbaecec3", + "integrity": "sha512-0itTOaI1rIoeXpT+mGxXH+e/E59dWGXHKqo/eyS9D6GpdGteTOEf3wuEdVO9Bcsyz5IWuZF3hVhSAY1Q/mA2OQ==", + "sourceCommit": "0285443b51889a6034dc24b5b603c0dbab099d4f", "workingTreeDirty": false }, { "name": "@zennotes/shared-domain", - "version": "2.53.0-boundaries.h193dbe157c4e64d2", - "file": "zennotes-shared-domain-2.53.0-boundaries.h193dbe157c4e64d2.tgz", - "sha256": "5e37187a39b69346ffb7c3b669823114bf18d0628ed773401c189fad3a31a746", - "integrity": "sha512-Uk53Icfq7IFasrxNrJN+tfGFFb5FaOgbb99IehRAmJZgkXG3IDW+RXsO4Sh3E4EqwTJk/sq68DCVmc1SNgbYtQ==", - "sourceCommit": "3a622639a245c928f63f1f61d0adfa110e0ca637", + "version": "2.54.1-boundaries.h1138e41f8e2ad650", + "file": "zennotes-shared-domain-2.54.1-boundaries.h1138e41f8e2ad650.tgz", + "sha256": "b4e6c071b4c43699d2a0cc27365bef9743b3f9ec9d136c6e614166e995c5c5ac", + "integrity": "sha512-g4cwRYlGf+tIlPzlzc4RjRtbbLUzppFfRU0GhwoPQ49UYmGyf5D+CVHfLXWTytkFp5Itd2+hsNUtPVRK+Wr5wQ==", + "sourceCommit": "0285443b51889a6034dc24b5b603c0dbab099d4f", "workingTreeDirty": false } ] diff --git a/vendor/zennotes/zennotes-app-core-2.53.0-core.h598c8d004c9228a3.tgz b/vendor/zennotes/zennotes-app-core-2.53.0-core.h598c8d004c9228a3.tgz deleted file mode 100644 index 7d5b4b9..0000000 Binary files a/vendor/zennotes/zennotes-app-core-2.53.0-core.h598c8d004c9228a3.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-app-core-2.54.1-core.h75d82a521571dc20.tgz b/vendor/zennotes/zennotes-app-core-2.54.1-core.h75d82a521571dc20.tgz new file mode 100644 index 0000000..29a4823 Binary files /dev/null and b/vendor/zennotes/zennotes-app-core-2.54.1-core.h75d82a521571dc20.tgz differ diff --git a/vendor/zennotes/zennotes-bridge-contract-2.53.0-boundaries.h193dbe157c4e64d2.tgz b/vendor/zennotes/zennotes-bridge-contract-2.53.0-boundaries.h193dbe157c4e64d2.tgz deleted file mode 100644 index 6543c95..0000000 Binary files a/vendor/zennotes/zennotes-bridge-contract-2.53.0-boundaries.h193dbe157c4e64d2.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-bridge-contract-2.54.1-boundaries.h1138e41f8e2ad650.tgz b/vendor/zennotes/zennotes-bridge-contract-2.54.1-boundaries.h1138e41f8e2ad650.tgz new file mode 100644 index 0000000..e993705 Binary files /dev/null and b/vendor/zennotes/zennotes-bridge-contract-2.54.1-boundaries.h1138e41f8e2ad650.tgz differ diff --git a/vendor/zennotes/zennotes-shared-domain-2.53.0-boundaries.h193dbe157c4e64d2.tgz b/vendor/zennotes/zennotes-shared-domain-2.53.0-boundaries.h193dbe157c4e64d2.tgz deleted file mode 100644 index 10ebef9..0000000 Binary files a/vendor/zennotes/zennotes-shared-domain-2.53.0-boundaries.h193dbe157c4e64d2.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-shared-domain-2.54.1-boundaries.h1138e41f8e2ad650.tgz b/vendor/zennotes/zennotes-shared-domain-2.54.1-boundaries.h1138e41f8e2ad650.tgz new file mode 100644 index 0000000..c7d025f Binary files /dev/null and b/vendor/zennotes/zennotes-shared-domain-2.54.1-boundaries.h1138e41f8e2ad650.tgz differ