apps relink: after a machine move, missing app folders are pointed at the same app here (last path parts, else the config's app name), ids kept; apps folders hints it; apps add names an app after its config app name

This commit is contained in:
CRTkafa 2026-10-22 06:22:08 +08:00
parent 9a907d87e5
commit cd0110527c
5 changed files with 134 additions and 5 deletions

View file

@ -2,6 +2,10 @@
## Unreleased
- **`storebound apps relink`** after a machine move: every "folder not found" entry of the app folder list is pointed at
the same app on this machine (by its path or its app name), ids kept so the desktop keeps its accounts and settings;
`apps folders` says so when entries are missing. `apps add` names an app after its config's app name.
- **Desktop: tray app that starts at logon and updates itself.** First run registers a logon entry (`--hidden`): the app
starts silently in the tray; closing the window keeps it there, the tray menu quits. A signed update is checked a
minute after start and every 30 minutes, installed silently, and the app restarts as it was.

View file

@ -9,6 +9,7 @@
// the same list as the desktop app's App folders)
// storebound apps add [<folder>] | apps remove <folder> add / remove a folder in that list (the folder itself stays)
// storebound apps add --store apple|play --app-id <id> [--account <profile>] [--name <n>] [--bundle-id <id>]
// storebound apps relink [--root <folder>]... [--dry] after a move: "folder not found" entries -> the same app here
// a store app with no project on this machine: the
// desktop shows it read-only ("no local project");
// apps remove apple:<id> | <entry id> takes it out
@ -17,7 +18,7 @@ import { ascKey, playServiceAccount } from './lib/credentials';
import type { AccountCheck, AppsListResult } from './lib/desktop-protocol';
import { PLAY_SCOPE, REPORTING_SCOPE, tokenSource } from './lib/google-auth';
import { errorBody } from './lib/http';
import { log, printJson, warn } from './lib/log';
import { done, log, printJson, warn } from './lib/log';
import { appleCheck, type Bytes, dataUrl, type Get, isServiceDisabled, listAppleApps, listPlayApps, localPlayApps, MAX_ICON_BYTES, playCheck } from './lib/store-apps';
import { textTable } from './lib/table';
import { ascClient } from './apple/asc';
@ -26,7 +27,8 @@ import * as rt from './lib/rt';
import { existsSync, readFileSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
import { CONFIG_NAMES } from './lib/config';
import { addFolder, addStoreApp, readRegistry, registryFile, removeFolder, removeStoreApp } from './lib/registry';
import { addFolder, addStoreApp, applyRelink, configAppName, configFolders, readRegistry, registryFile, relinkPlan, removeFolder, removeStoreApp } from './lib/registry';
import { homedir } from 'node:os';
type Store = 'apple' | 'play';
@ -175,6 +177,8 @@ async function folders(sub: string, args: Args): Promise<void> {
}
if (!r.projects.length) { console.log(`no app folders yet (${tilde(registryFile())}): storebound apps add, in an app folder (or storebound desktop there)`); return; }
console.log(textTable(['app', 'folder', ''], r.projects.map(p => [p.name, tilde(p.path), existsSync(p.path) ? '' : 'folder not found'])));
const missing = r.projects.filter(p => !existsSync(p.path)).length;
if (missing) console.log(`\n${missing} folder(s) not found (moved to another computer or folder?): storebound apps relink --dry finds them by path and app name`);
console.log(`
${tilde(registryFile())}: the same list as the desktop app's App folders`);
return;
@ -218,10 +222,25 @@ ${tilde(registryFile())}: the same list as the desktop app's App folders`);
log('apps', removed ? `removed ${tilde(removed.path)} from the list (the folder and its files stay)` : `${tilde(dir)} is not in the list`);
}
/** apps relink: point "folder not found" entries at the same app on this machine (after a move); ids stay. */
function relink(args: Args): void {
const home = homedir();
const roots = args.list('root').length ? args.list('root').map(r => resolve(r))
: [join(home, 'Documents', 'Visual Studio Workspaces'), join(home, 'Documents'), join(home, 'Desktop'), join(home, 'Projects'), join(home, 'code')].filter(d => existsSync(d));
const plan = relinkPlan(readRegistry(), configFolders(roots), existsSync, configAppName);
if (args.has('json')) { printJson({ roots, plan, dry: args.has('dry') }); }
else if (!plan.length) log('apps', `nothing to relink (looked under ${roots.map(tilde).join(', ')}; --root <folder> to look elsewhere)`);
else for (const s of plan) log('apps', `${s.name}: ${s.from} -> ${tilde(s.to)}`);
if (!plan.length || args.has('dry')) { if (plan.length) done('apps', `dry run: ${plan.length} folder(s) would be relinked`); return; }
applyRelink(plan);
done('apps', `${plan.length} folder(s) relinked (the desktop app picks them up on its next start)`);
}
export async function run(args: Args): Promise<void> {
const sub = args._[0];
const root = process.cwd();
const json = args.has('json');
if (sub === 'relink') return relink(args);
if (sub === 'folders' || sub === 'add' || sub === 'remove') return folders(sub, args);
if (sub === 'list') {
const out: AppsListResult[] = [];

View file

@ -317,6 +317,7 @@
"bundle-id",
"icons",
"name",
"root",
"store"
],
"art": [
@ -621,6 +622,9 @@
"play listing": [
"include-drafts",
"not-sent-for-review",
"prune",
"src",
"variant",
"write"
],
"play next-version-code": [],
@ -636,6 +640,9 @@
"name",
"not-sent-for-review",
"production",
"prune",
"src",
"variant",
"version-code",
"write"
],
@ -683,8 +690,11 @@
"include-drafts",
"name",
"not-sent-for-review",
"prune",
"src",
"status",
"track",
"variant",
"write"
],
"prices": [
@ -1115,6 +1125,7 @@
"resolve-iaps",
"resume",
"review-sent",
"root",
"scenes",
"scheme",
"sdk-dirs",
@ -1337,7 +1348,8 @@
"storebound apps check [--store apple|play] [--json] auth OK / permission missing / key revoked, with the fix",
"storebound apps folders [--json] the app folders of this machine (~/.storebound/projects.json,",
"storebound apps add [<folder>] | apps remove <folder> add / remove a folder in that list (the folder itself stays)",
"storebound apps add --store apple|play --app-id <id> [--account <profile>] [--name <n>] [--bundle-id <id>]"
"storebound apps add --store apple|play --app-id <id> [--account <profile>] [--name <n>] [--bundle-id <id>]",
"storebound apps relink [--root <folder>]... [--dry] after a move: \"folder not found\" entries -> the same app here"
],
"art": [
"storebound art pull [--store apple|play] [--write] [--force] [--dry] [--json]",

View file

@ -12,7 +12,7 @@
// Written atomically (temp file, rename); a missing or unreadable file reads as empty (an unreadable one is kept).
import { consoleLink } from './console-links';
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
@ -87,7 +87,7 @@ export function withFolder(r: Registry, path: string, name: string | undefined,
const abs = resolve(path);
const have = r.projects.find(p => p.path === abs);
if (have) return { registry: r, project: have, added: false };
const project = { id: folderId(abs), path: abs, name: name?.trim() || basename(abs), added: now };
const project = { id: folderId(abs), path: abs, name: name?.trim() || configAppName(abs) || basename(abs), added: now };
return { registry: { ...r, version: 1, projects: [...r.projects, project] }, project, added: true };
}
@ -144,3 +144,64 @@ export function removeFolder(pathOrId: string, file = registryFile()): RegistryP
if (removed) writeRegistry(registry, file);
return removed;
}
/** The app name a storebound config gives (`app: { name: '...' }`), read as text (no config load), else null. */
export function configAppName(dir: string): string | null {
for (const n of ['storebound.config.ts', 'storebound.config.mts', 'storebound.config.js', 'storebound.config.mjs']) {
try {
const text = readFileSync(join(dir, n), 'utf8');
const m = /\bapp\s*:\s*\{[^}]*?\bname\s*:\s*(['"`])([^'"`]+)\1/s.exec(text);
if (m) return m[2]!.trim();
} catch { /* not this one */ }
}
return null;
}
/** Folders with a storebound config under the roots (depth 4; node_modules, .git and _arsiv skipped). */
export function configFolders(roots: string[], depth = 4): string[] {
const out: string[] = [];
const walk = (d: string, left: number) => {
let names: string[] = [];
try { names = readdirSync(d); } catch { return; }
if (names.some(n => /^storebound\.config\.(ts|mts|js|mjs)$/.test(n))) out.push(d);
if (left <= 0) return;
for (const n of names) {
if (n === 'node_modules' || n.startsWith('.') || n === '_arsiv') continue;
const p = join(d, n);
try { if (statSync(p).isDirectory()) walk(p, left - 1); } catch { /* unreadable */ }
}
};
for (const r of roots) walk(r, depth);
return [...new Set(out)];
}
/**
* After a machine move the list keeps the old computer's paths ("folder not found"). For each missing folder: a
* config folder under the roots whose last path parts match (some-game, Acme/frontend), else one whose app name is
* the same as the entry's name. The id stays, so the desktop's per-app accounts and settings stay linked. Pure.
*/
export function relinkPlan(r: Registry, candidates: string[], exists: (p: string) => boolean, nameOf: (dir: string) => string | null): { id: string; name: string; from: string; to: string }[] {
const parts = (p: string) => p.split(/[\\/]+/).filter(Boolean).map(x => x.toLowerCase());
const taken = new Set(r.projects.filter(p => exists(p.path)).map(p => resolve(p.path).toLowerCase()));
const plan: { id: string; name: string; from: string; to: string }[] = [];
for (const p of r.projects) {
if (exists(p.path)) continue;
const want = parts(p.path);
const free = candidates.filter(c => !taken.has(resolve(c).toLowerCase()));
let best: string | undefined;
for (const k of [3, 2, 1]) {
const tail = want.slice(-k).join('/');
best = free.find(c => parts(c).slice(-k).join('/') === tail);
if (best) break;
}
best ??= free.find(c => nameOf(c)?.toLowerCase() === p.name.toLowerCase());
if (best) { plan.push({ id: p.id, name: p.name, from: p.path, to: resolve(best) }); taken.add(resolve(best).toLowerCase()); }
}
return plan;
}
export function applyRelink(plan: { id: string; to: string }[], file = registryFile()): void {
const r = readRegistry(file);
for (const s of plan) { const p = r.projects.find(x => x.id === s.id); if (p) p.path = s.to; }
writeRegistry(r, file);
}

View file

@ -0,0 +1,33 @@
// After a machine move the app folder list points at the old computer; relink finds each app here and keeps its id.
import { describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { configAppName, configFolders, relinkPlan } from '../cli/lib/registry';
function app(root: string, rel: string, name: string): string {
const d = join(root, rel); mkdirSync(d, { recursive: true });
writeFileSync(join(d, 'storebound.config.ts'), `export default { app: { name: '${name}' } };\n`);
return d;
}
describe('apps relink', () => {
test('by the last path parts, else by the app name; ids stay; a taken folder is not reused', () => {
const root = mkdtempSync(join(tmpdir(), 'sb-relink-'));
const game = app(root, 'game-one', 'Game One');
const front = app(root, 'Studio/frontend', 'Studio');
const renamed = app(root, 'NewName/app', 'Old App');
mkdirSync(join(root, 'node_modules/x'), { recursive: true }); writeFileSync(join(root, 'node_modules/x/storebound.config.ts'), '');
const found = configFolders([root]);
expect(found.sort()).toEqual([game, front, renamed].sort());
expect(configAppName(front)).toBe('Studio');
const r = { version: 1 as const, projects: [
{ id: 'a', path: '/Users/old/Desktop/game-one', name: 'Game One', added: '' },
{ id: 'b', path: '/Users/old/Work/Studio/frontend', name: 'frontend', added: '' },
{ id: 'c', path: '/Users/old/Work/OldName/app', name: 'Old App', added: '' },
{ id: 'd', path: '/Users/old/gone', name: 'Gone', added: '' },
] };
const plan = relinkPlan(r, found, () => false, configAppName);
expect(plan.map(p => [p.id, p.to])).toEqual([['a', game], ['b', front], ['c', renamed]]);
});
});