One product per machine: the CLI and the desktop app share one version, the store accounts and the app folder list
- one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG - storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S, Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download; the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl/<version>/latest.json - credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI made; precedence env > named profile > project file > default profile; config `account`, "account" in the project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use - app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop, apps folders | add | remove, ws init and ws without a workspace file - desktop: notice when a folder pins another storebound version; storebound-desktop --version - tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust), keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state
This commit is contained in:
parent
bba46b5c7f
commit
d2ce185441
60 changed files with 2443 additions and 246 deletions
|
|
@ -313,6 +313,32 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br
|
|||
if (!text.includes('Folder not found') || !text.includes('Locate…') || !text.includes('Remove from list')) problems.push(`${p.name}: no "Folder not found" panel with Locate… / Remove`);
|
||||
else notes.push(`${p.name}: Folder not found, Locate… / Remove`);
|
||||
}
|
||||
// shared with the CLI: each account names its credential profile (or that its old keychain key has not moved)
|
||||
const accts = fx.accounts_list as { label?: string; profile?: string | null }[];
|
||||
if (accts.length) {
|
||||
const nav = page.locator('nav.side button.nav', { hasText: 'Accounts' }).first();
|
||||
if (await nav.count()) {
|
||||
await nav.click({ timeout: WAIT_MS });
|
||||
await settle(page);
|
||||
const text = await page.locator('#content').innerText();
|
||||
for (const a of accts) {
|
||||
const want = a.profile ? `profile ${a.profile}` : 'key not moved from the keychain yet';
|
||||
if (!text.includes(want)) problems.push(`Accounts: "${want}" not shown for ${a.label} (${text.replace(/\s+/g, " ").slice(text.replace(/\s+/g, " ").indexOf(a.label ?? "") , 400)})`);
|
||||
}
|
||||
notes.push(`Accounts: ${accts.map(a => a.profile ?? '(keychain)').join(', ')}`);
|
||||
} else problems.push('no Accounts entry in the sidebar');
|
||||
}
|
||||
// a folder that pins another storebound version says so on its app page
|
||||
for (const p of fx.projects_list.filter(x => (x as { storeboundPin?: string }).storeboundPin && isDir(x.path))) {
|
||||
const pin = (p as { storeboundPin?: string }).storeboundPin!;
|
||||
const item = page.locator('nav.side .nav-app', { hasText: p.name }).first();
|
||||
if (!(await item.count())) { problems.push(`${p.name}: not in the sidebar`); continue; }
|
||||
await item.click({ timeout: WAIT_MS });
|
||||
await settle(page);
|
||||
const text = await page.locator('#content').innerText();
|
||||
if (!text.includes(`uses storebound ${pin}`)) problems.push(`${p.name}: no "project uses storebound ${pin}" notice`);
|
||||
else notes.push(`${p.name}: pin notice ${pin}`);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
server.stop(true);
|
||||
|
|
|
|||
|
|
@ -19,12 +19,14 @@
|
|||
// bun tools/desktop-release.ts publish --out <dir> --host <user@host> [--channel stable|beta] [--yes]
|
||||
// Checks the folder again, uploads it to the server's incoming folder (rsync) and runs the root-owned publish
|
||||
// script there (tmp -> mv, latest.json last). Without --yes it only prints the plan (dry run).
|
||||
// The folder's version must be package.json's: the npm CLI and the app are released together, the app first,
|
||||
// then `npm publish` of the same version.
|
||||
// bun tools/desktop-release.ts verify --out <dir>
|
||||
// The checks of publish without uploading.
|
||||
// bun tools/desktop-release.ts exec -- <command...>
|
||||
// Runs a build command (tauri build on a runner) with the path-free environment described at pathFreeEnv.
|
||||
//
|
||||
// Version: package.json "desktopVersion" (bun tools/desktop-version.ts). Docs: docs/desktop-release.md.
|
||||
// Version: package.json "version", the same as the npm CLI (bun tools/desktop-version.ts); both ship together. Docs: docs/desktop-release.md.
|
||||
import { spawnSync, type SpawnSyncOptions } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
|
|
@ -634,6 +636,13 @@ function verifyFolder(out: string): { manifest: Manifest; files: string[] } {
|
|||
return { manifest, files };
|
||||
}
|
||||
|
||||
/** The npm CLI and the desktop app are one release: the folder must be the version of this checkout's package.json
|
||||
* (the CLI that `npx storebound desktop` installs it for). Pure. */
|
||||
export function releaseTogetherProblems(folderVersion: string, pkgVersion: string | null): string[] {
|
||||
if (!pkgVersion) return ['package.json has no version'];
|
||||
return folderVersion === pkgVersion ? [] : [`the folder is desktop ${folderVersion} but package.json (the npm CLI) is ${pkgVersion}: the CLI and the app ship with one version (bun tools/desktop-version.ts --set ${folderVersion}, or build the folder from the release commit)`];
|
||||
}
|
||||
|
||||
function publish(argv: string[]): void {
|
||||
const a = args(argv);
|
||||
const out = resolve(a.get('out') ?? fail('--out <version folder> is required'));
|
||||
|
|
@ -643,6 +652,8 @@ function publish(argv: string[]): void {
|
|||
if (!['stable', 'beta'].includes(channel)) fail('--channel stable or beta');
|
||||
const { manifest, files } = verifyFolder(out);
|
||||
const v = manifest.version;
|
||||
const together = releaseTogetherProblems(v, versions().version);
|
||||
if (together.length) fail(together.join('; '));
|
||||
const unsigned = Object.values(manifest.downloads ?? {}).filter(d => !d!.codeSigned && /\.(dmg|exe)$/.test(d!.file)).map(d => d!.file);
|
||||
const steps = [
|
||||
['ssh', host, `mkdir -p ${REMOTE_INCOMING}/${v} && chmod 700 ${REMOTE_INCOMING}`],
|
||||
|
|
@ -659,6 +670,7 @@ function publish(argv: string[]): void {
|
|||
const live = spawnSync('curl', ['-fsS', channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/beta/latest.json`], { encoding: 'utf8' });
|
||||
if (live.status !== 0 || (JSON.parse(live.stdout) as Manifest).version !== v) fail('latest.json on storebound.dev is not the new version yet');
|
||||
say(`live: ${BASE_URL}/ serves ${v}`);
|
||||
say(`next, the same release: npm publish of storebound ${v} (CONTRIBUTING.md). \`npx storebound desktop\` of ${v} installs or updates to this app.`);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
#!/usr/bin/env bun
|
||||
// One version for the desktop app. The authority is `desktopVersion` in the root package.json (the npm package's
|
||||
// own `version` is the CLI's and moves on its own); it is copied into apps/desktop/package.json,
|
||||
// src-tauri/tauri.conf.json, src-tauri/Cargo.toml and the app's entry in src-tauri/Cargo.lock.
|
||||
// One version for storebound: the npm CLI and the desktop app are one product and are released together (0.8.0 was
|
||||
// the first release with one number). The authority is `version` in the root package.json; it is copied into the
|
||||
// agent plugin manifests (written by tools/gen-adapters.ts), apps/desktop/package.json, src-tauri/tauri.conf.json,
|
||||
// src-tauri/Cargo.toml and the app's entry in src-tauri/Cargo.lock. `storebound desktop` installs, opens or updates the
|
||||
// app of exactly this version.
|
||||
// bun tools/desktop-version.ts check: exit 1 when any copy drifted (CI runs this)
|
||||
// bun tools/desktop-version.ts --set 0.1.1 write the new version into the authority and every copy
|
||||
// bun tools/desktop-version.ts --set 0.8.0 write the new version into package.json and every copy
|
||||
// bun tools/desktop-version.ts --print print the version
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
|
@ -14,16 +16,22 @@ export const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|||
|
||||
export interface Copy { file: string; read(text: string): string | null; write(text: string, v: string): string }
|
||||
|
||||
const jsonVersion = (key: string): Pick<Copy, 'read' | 'write'> => ({
|
||||
read: t => { const v = (JSON.parse(t) as Record<string, unknown>)[key]; return typeof v === 'string' ? v : null; },
|
||||
write: (t, v) => t.replace(new RegExp(`("${key}"\\s*:\\s*)"[^"]*"`), `$1"${v}"`),
|
||||
});
|
||||
/** The first `"version": "..."` of a JSON file (top level in package.json and the manifests, the plugin entry of
|
||||
* marketplace.json). */
|
||||
const jsonVersion: Pick<Copy, 'read' | 'write'> = {
|
||||
read: t => /"version"\s*:\s*"([^"]*)"/.exec(t)?.[1] ?? null,
|
||||
write: (t, v) => t.replace(/("version"\s*:\s*)"[^"]*"/, `$1"${v}"`),
|
||||
};
|
||||
|
||||
/** The authority first, then every copy. */
|
||||
export const COPIES: Copy[] = [
|
||||
{ file: 'package.json', ...jsonVersion('desktopVersion') },
|
||||
{ file: 'apps/desktop/package.json', ...jsonVersion('version') },
|
||||
{ file: `${TAURI}/tauri.conf.json`, ...jsonVersion('version') },
|
||||
{ file: 'package.json', ...jsonVersion },
|
||||
{ file: '.claude-plugin/plugin.json', ...jsonVersion },
|
||||
{ file: '.claude-plugin/marketplace.json', ...jsonVersion },
|
||||
{ file: '.codex-plugin/plugin.json', ...jsonVersion },
|
||||
{ file: 'gemini-extension.json', ...jsonVersion },
|
||||
{ file: 'apps/desktop/package.json', ...jsonVersion },
|
||||
{ file: `${TAURI}/tauri.conf.json`, ...jsonVersion },
|
||||
{
|
||||
file: `${TAURI}/Cargo.toml`,
|
||||
// the [package] table's version (the first `version =` line of the file, before any dependency table)
|
||||
|
|
@ -61,13 +69,13 @@ if (import.meta.main) {
|
|||
const i = argv.indexOf('--set');
|
||||
if (i >= 0) {
|
||||
setVersion(argv[i + 1] ?? '');
|
||||
console.log(`desktop version ${argv[i + 1]} written to ${COPIES.map(c => c.file).join(', ')}`);
|
||||
console.log(`version ${argv[i + 1]} written to ${COPIES.map(c => c.file).join(', ')}\nnext: a CHANGELOG section for it; the npm package and the desktop app are released together (docs/desktop-release.md)`);
|
||||
process.exit(0);
|
||||
}
|
||||
const { version, drift } = versions();
|
||||
if (argv.includes('--print')) { console.log(version ?? ''); process.exit(version ? 0 : 1); }
|
||||
if (!version || !SEMVER.test(version)) { console.error('desktop version: package.json has no valid "desktopVersion"'); process.exit(1); }
|
||||
for (const d of drift) console.error(`desktop version drift: ${d.file} has ${d.found ?? 'no version'}, package.json desktopVersion is ${version}`);
|
||||
if (!version || !SEMVER.test(version)) { console.error('version: package.json has no valid "version"'); process.exit(1); }
|
||||
for (const d of drift) console.error(`version drift: ${d.file} has ${d.found ?? 'no version'}, package.json version is ${version}`);
|
||||
if (drift.length) { console.error('fix: bun tools/desktop-version.ts --set ' + version); process.exit(1); }
|
||||
console.log(`desktop version ${version}: ${COPIES.length - 1} copies match`);
|
||||
console.log(`version ${version}: the CLI, the plugin manifests and the desktop app (${COPIES.length - 1} copies) match`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
# It checks the upload (regular files with plain names only, SHA256SUMS, latest.json of that version whose every URL
|
||||
# points at an uploaded file of /desktop/dl/<version>/), copies the files into /var/www/storebound-desktop/dl/<version>/
|
||||
# (a new version: a temp folder renamed into place; an existing version: only new files, a file already published is
|
||||
# never replaced by different bytes), and only then swaps latest.json of the channel atomically (temp file + rename).
|
||||
# never replaced by different bytes), writes the version's own dl/<version>/latest.json (what `storebound desktop` of
|
||||
# the same CLI version installs), and only then swaps latest.json of the channel atomically (temp file + rename).
|
||||
# Apache serves that folder under https://storebound.dev/desktop/ (tools/vds/apache-storebound-desktop.conf), apart
|
||||
# from the site's own rsync, which deletes what is not in the repository. Nothing from the upload is executed.
|
||||
set -euo pipefail
|
||||
|
|
@ -70,6 +71,10 @@ else
|
|||
install -m 644 "$IN/SHA256SUMS" "$DL/.SHA256SUMS.tmp" && mv -f "$DL/.SHA256SUMS.tmp" "$DL/SHA256SUMS"
|
||||
fi
|
||||
|
||||
# the version's own manifest: dl/<version>/latest.json. `storebound desktop` of CLI <version> installs exactly that
|
||||
# version from it (the CLI and the app share one version number); it may be updated when installers are added
|
||||
install -m 644 "$IN/latest.json" "$DL/.latest.json.tmp" && mv -f "$DL/.latest.json.tmp" "$DL/latest.json"
|
||||
|
||||
# latest.json last: installed apps see the new version only once every file it names is in place
|
||||
if [ "$CHANNEL" = stable ]; then LJ="$ROOT/latest.json"; URL="$SITE/latest.json"; else LJ="$ROOT/beta/latest.json"; URL="$SITE/beta/latest.json"; fi
|
||||
install -m 644 "$IN/latest.json" "$LJ.tmp"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue