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:
parent
9a907d87e5
commit
cd0110527c
5 changed files with 134 additions and 5 deletions
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## Unreleased
|
## 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
|
- **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
|
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.
|
minute after start and every 30 minutes, installed silently, and the app restarts as it was.
|
||||||
|
|
|
||||||
23
cli/apps.ts
23
cli/apps.ts
|
|
@ -9,6 +9,7 @@
|
||||||
// the same list as the desktop app's App folders)
|
// 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 [<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
|
||||||
// a store app with no project on this machine: the
|
// a store app with no project on this machine: the
|
||||||
// desktop shows it read-only ("no local project");
|
// desktop shows it read-only ("no local project");
|
||||||
// apps remove apple:<id> | <entry id> takes it out
|
// 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 type { AccountCheck, AppsListResult } from './lib/desktop-protocol';
|
||||||
import { PLAY_SCOPE, REPORTING_SCOPE, tokenSource } from './lib/google-auth';
|
import { PLAY_SCOPE, REPORTING_SCOPE, tokenSource } from './lib/google-auth';
|
||||||
import { errorBody } from './lib/http';
|
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 { appleCheck, type Bytes, dataUrl, type Get, isServiceDisabled, listAppleApps, listPlayApps, localPlayApps, MAX_ICON_BYTES, playCheck } from './lib/store-apps';
|
||||||
import { textTable } from './lib/table';
|
import { textTable } from './lib/table';
|
||||||
import { ascClient } from './apple/asc';
|
import { ascClient } from './apple/asc';
|
||||||
|
|
@ -26,7 +27,8 @@ import * as rt from './lib/rt';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { basename, join, resolve } from 'node:path';
|
import { basename, join, resolve } from 'node:path';
|
||||||
import { CONFIG_NAMES } from './lib/config';
|
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';
|
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; }
|
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'])));
|
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(`
|
console.log(`
|
||||||
${tilde(registryFile())}: the same list as the desktop app's App folders`);
|
${tilde(registryFile())}: the same list as the desktop app's App folders`);
|
||||||
return;
|
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`);
|
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> {
|
export async function run(args: Args): Promise<void> {
|
||||||
const sub = args._[0];
|
const sub = args._[0];
|
||||||
const root = process.cwd();
|
const root = process.cwd();
|
||||||
const json = args.has('json');
|
const json = args.has('json');
|
||||||
|
if (sub === 'relink') return relink(args);
|
||||||
if (sub === 'folders' || sub === 'add' || sub === 'remove') return folders(sub, args);
|
if (sub === 'folders' || sub === 'add' || sub === 'remove') return folders(sub, args);
|
||||||
if (sub === 'list') {
|
if (sub === 'list') {
|
||||||
const out: AppsListResult[] = [];
|
const out: AppsListResult[] = [];
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,7 @@
|
||||||
"bundle-id",
|
"bundle-id",
|
||||||
"icons",
|
"icons",
|
||||||
"name",
|
"name",
|
||||||
|
"root",
|
||||||
"store"
|
"store"
|
||||||
],
|
],
|
||||||
"art": [
|
"art": [
|
||||||
|
|
@ -621,6 +622,9 @@
|
||||||
"play listing": [
|
"play listing": [
|
||||||
"include-drafts",
|
"include-drafts",
|
||||||
"not-sent-for-review",
|
"not-sent-for-review",
|
||||||
|
"prune",
|
||||||
|
"src",
|
||||||
|
"variant",
|
||||||
"write"
|
"write"
|
||||||
],
|
],
|
||||||
"play next-version-code": [],
|
"play next-version-code": [],
|
||||||
|
|
@ -636,6 +640,9 @@
|
||||||
"name",
|
"name",
|
||||||
"not-sent-for-review",
|
"not-sent-for-review",
|
||||||
"production",
|
"production",
|
||||||
|
"prune",
|
||||||
|
"src",
|
||||||
|
"variant",
|
||||||
"version-code",
|
"version-code",
|
||||||
"write"
|
"write"
|
||||||
],
|
],
|
||||||
|
|
@ -683,8 +690,11 @@
|
||||||
"include-drafts",
|
"include-drafts",
|
||||||
"name",
|
"name",
|
||||||
"not-sent-for-review",
|
"not-sent-for-review",
|
||||||
|
"prune",
|
||||||
|
"src",
|
||||||
"status",
|
"status",
|
||||||
"track",
|
"track",
|
||||||
|
"variant",
|
||||||
"write"
|
"write"
|
||||||
],
|
],
|
||||||
"prices": [
|
"prices": [
|
||||||
|
|
@ -1115,6 +1125,7 @@
|
||||||
"resolve-iaps",
|
"resolve-iaps",
|
||||||
"resume",
|
"resume",
|
||||||
"review-sent",
|
"review-sent",
|
||||||
|
"root",
|
||||||
"scenes",
|
"scenes",
|
||||||
"scheme",
|
"scheme",
|
||||||
"sdk-dirs",
|
"sdk-dirs",
|
||||||
|
|
@ -1337,7 +1348,8 @@
|
||||||
"storebound apps check [--store apple|play] [--json] auth OK / permission missing / key revoked, with the fix",
|
"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 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 [<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": [
|
"art": [
|
||||||
"storebound art pull [--store apple|play] [--write] [--force] [--dry] [--json]",
|
"storebound art pull [--store apple|play] [--write] [--force] [--dry] [--json]",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
// Written atomically (temp file, rename); a missing or unreadable file reads as empty (an unreadable one is kept).
|
// Written atomically (temp file, rename); a missing or unreadable file reads as empty (an unreadable one is kept).
|
||||||
import { consoleLink } from './console-links';
|
import { consoleLink } from './console-links';
|
||||||
import { createHash } from 'node:crypto';
|
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 { homedir } from 'node:os';
|
||||||
import { basename, dirname, join, resolve } from 'node:path';
|
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 abs = resolve(path);
|
||||||
const have = r.projects.find(p => p.path === abs);
|
const have = r.projects.find(p => p.path === abs);
|
||||||
if (have) return { registry: r, project: have, added: false };
|
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 };
|
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);
|
if (removed) writeRegistry(registry, file);
|
||||||
return removed;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
33
tests/registry-relink.test.ts
Normal file
33
tests/registry-relink.test.ts
Normal 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]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue