@shogo-ai/worker 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +432 -0
- package/bin/shogo.mjs +38 -0
- package/dist/cli.mjs +64 -0
- package/package.json +62 -0
- package/src/cli.ts +199 -0
- package/src/commands/agent.ts +123 -0
- package/src/commands/config.ts +29 -0
- package/src/commands/doctor.ts +128 -0
- package/src/commands/login.ts +126 -0
- package/src/commands/logs.ts +24 -0
- package/src/commands/project-checkout.ts +179 -0
- package/src/commands/project-pull.ts +133 -0
- package/src/commands/project-push.ts +89 -0
- package/src/commands/runtime.ts +104 -0
- package/src/commands/start.ts +290 -0
- package/src/commands/status.ts +22 -0
- package/src/commands/stop.ts +13 -0
- package/src/lib/__tests__/agent-launch.test.ts +92 -0
- package/src/lib/__tests__/cloud-login-coverage.test.ts +210 -0
- package/src/lib/__tests__/cloud-login.test.ts +218 -0
- package/src/lib/__tests__/cloud-sync-watcher.test.ts +214 -0
- package/src/lib/__tests__/config.test.ts +141 -0
- package/src/lib/__tests__/db-doctor.test.ts +204 -0
- package/src/lib/__tests__/git-cloner.test.ts +382 -0
- package/src/lib/__tests__/runtime-manager-auto-pull.test.ts +275 -0
- package/src/lib/__tests__/runtime-manager-cloud-sync-gate.test.ts +119 -0
- package/src/lib/__tests__/runtime-manager-coverage-gaps.test.ts +768 -0
- package/src/lib/__tests__/runtime-manager-describe-rejection.test.ts +42 -0
- package/src/lib/__tests__/runtime-manager-git-pull.test.ts +207 -0
- package/src/lib/__tests__/runtime-manager-idle-eviction.test.ts +196 -0
- package/src/lib/__tests__/runtime-manager-restart-cap.test.ts +310 -0
- package/src/lib/__tests__/runtime-manager-tree-sitter-env.test.ts +189 -0
- package/src/lib/__tests__/runtime-manager-wait-for-health.test.ts +269 -0
- package/src/lib/__tests__/runtime-resolver.test.ts +112 -0
- package/src/lib/__tests__/tunnel-coverage.test.ts +1094 -0
- package/src/lib/__tests__/tunnel-structured-502.test.ts +101 -0
- package/src/lib/__tests__/worker-commands-greenfield.test.ts +463 -0
- package/src/lib/__tests__/worker-libs-greenfield.test.ts +355 -0
- package/src/lib/__tests__/worker-transport-greenfield.test.ts +176 -0
- package/src/lib/api-discovery.ts +36 -0
- package/src/lib/cloud-login.ts +320 -0
- package/src/lib/cloud-sync-watcher.ts +322 -0
- package/src/lib/config.ts +68 -0
- package/src/lib/db-doctor.ts +355 -0
- package/src/lib/device-id.ts +27 -0
- package/src/lib/git-cloner.ts +354 -0
- package/src/lib/paths.ts +53 -0
- package/src/lib/preflight.ts +158 -0
- package/src/lib/process-manager.ts +123 -0
- package/src/lib/resource-limits.ts +340 -0
- package/src/lib/runtime-install.ts +371 -0
- package/src/lib/runtime-manager.ts +1873 -0
- package/src/lib/runtime-resolver.ts +136 -0
- package/src/lib/transport.ts +202 -0
- package/src/lib/tunnel.ts +710 -0
- package/src/lib/user-agent.ts +28 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Reads/writes ~/.shogo/config.json. The worker reads this at start,
|
|
5
|
+
* and the `config` / `login` commands write to it.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs';
|
|
8
|
+
import { hostname } from 'node:os';
|
|
9
|
+
import { CONFIG_FILE, PROJECTS_DIR, ensureHome } from './paths.ts';
|
|
10
|
+
|
|
11
|
+
export interface WorkerConfig {
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
cloudUrl?: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
workerDir?: string;
|
|
16
|
+
port?: number;
|
|
17
|
+
/** Root directory under which pulled project workspaces are stored. */
|
|
18
|
+
projectsDir?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULTS: { cloudUrl: string; port: number; projectsDir: string } = {
|
|
22
|
+
cloudUrl: 'https://studio.shogo.ai',
|
|
23
|
+
port: 8002,
|
|
24
|
+
projectsDir: PROJECTS_DIR,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function loadConfig(): WorkerConfig {
|
|
28
|
+
if (!existsSync(CONFIG_FILE)) return {};
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
|
|
31
|
+
} catch (err) {
|
|
32
|
+
throw new Error(`Corrupt config at ${CONFIG_FILE}: ${(err as Error).message}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function saveConfig(cfg: WorkerConfig): void {
|
|
37
|
+
ensureHome();
|
|
38
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
39
|
+
chmodSync(CONFIG_FILE, 0o600);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function mergeConfig(base: WorkerConfig, override: WorkerConfig): WorkerConfig {
|
|
43
|
+
return { ...base, ...Object.fromEntries(Object.entries(override).filter(([, v]) => v !== undefined)) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveConfig(override: WorkerConfig = {}): Required<WorkerConfig> & { apiKey: string } {
|
|
47
|
+
const fileCfg = loadConfig();
|
|
48
|
+
const env: WorkerConfig = {
|
|
49
|
+
apiKey: process.env.SHOGO_API_KEY,
|
|
50
|
+
cloudUrl: process.env.SHOGO_CLOUD_URL,
|
|
51
|
+
name: process.env.SHOGO_INSTANCE_NAME,
|
|
52
|
+
workerDir: process.env.SHOGO_WORKER_DIR,
|
|
53
|
+
port: process.env.PORT ? parseInt(process.env.PORT, 10) : undefined,
|
|
54
|
+
projectsDir: process.env.SHOGO_PROJECTS_DIR,
|
|
55
|
+
};
|
|
56
|
+
const merged = mergeConfig(mergeConfig(fileCfg, env), override);
|
|
57
|
+
if (!merged.apiKey) {
|
|
58
|
+
throw new Error('No API key. Run `shogo login` or pass --api-key / set SHOGO_API_KEY.');
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
apiKey: merged.apiKey,
|
|
62
|
+
cloudUrl: merged.cloudUrl ?? DEFAULTS.cloudUrl,
|
|
63
|
+
name: merged.name ?? hostname(),
|
|
64
|
+
workerDir: merged.workerDir ?? process.cwd(),
|
|
65
|
+
port: merged.port ?? DEFAULTS.port,
|
|
66
|
+
projectsDir: merged.projectsDir ?? DEFAULTS.projectsDir,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
//
|
|
4
|
+
// Local SQLite migration "doctor" for the Shogo desktop app.
|
|
5
|
+
//
|
|
6
|
+
// The packaged desktop app stores its database at
|
|
7
|
+
// `<userData>/data/shogo.db` and applies schema with
|
|
8
|
+
// `prisma migrate deploy` on every launch. When a migration throws
|
|
9
|
+
// mid-way it leaves a row in `_prisma_migrations` with
|
|
10
|
+
// `finished_at = NULL`, and Prisma's P3009 check then refuses to run
|
|
11
|
+
// ANY further migrations — the app is wedged until that ledger row is
|
|
12
|
+
// cleared. The desktop app surfaces a recovery dialog when this happens
|
|
13
|
+
// on boot (see apps/desktop/src/db-recovery.ts), but a user whose app
|
|
14
|
+
// won't open, or who is being walked through a fix by support, has no
|
|
15
|
+
// way to trigger that repair from a terminal.
|
|
16
|
+
//
|
|
17
|
+
// `shogo doctor` (this module) is that terminal entry point. It performs
|
|
18
|
+
// the SAME safe, dependency-free repair the desktop dialog does:
|
|
19
|
+
//
|
|
20
|
+
// 1. detectFailedMigrations() — find stuck `_prisma_migrations` rows.
|
|
21
|
+
// 2. backupDatabase() — snapshot `shogo.db` (+ -wal/-shm)
|
|
22
|
+
// before touching anything.
|
|
23
|
+
// 3. repairFailedMigrations() — delete the stuck rows (equivalent to
|
|
24
|
+
// `prisma migrate resolve --rolled-back`).
|
|
25
|
+
//
|
|
26
|
+
// It deliberately does NOT re-run `prisma migrate deploy` itself — the
|
|
27
|
+
// CLI doesn't ship the Prisma schema/migration history that lives inside
|
|
28
|
+
// the desktop app bundle. Instead it clears the wedge and tells the user
|
|
29
|
+
// to relaunch Shogo, which re-applies migrations on its next boot.
|
|
30
|
+
//
|
|
31
|
+
// Why shell out to bun rather than link a SQLite driver
|
|
32
|
+
// -----------------------------------------------------
|
|
33
|
+
// The worker CLI runs under Node (npm install) OR Bun (tarball release),
|
|
34
|
+
// and we don't want a native `better-sqlite3` build step. The desktop
|
|
35
|
+
// app already ships a `bun` binary with `bun:sqlite` statically linked,
|
|
36
|
+
// and that's the exact SQLite version Prisma's bun-sqlite adapter uses at
|
|
37
|
+
// runtime — so running `bun -e "<small script>"` against it is both
|
|
38
|
+
// dependency-free and driver-version-matched. This mirrors
|
|
39
|
+
// apps/desktop/src/db-recovery.ts (kept as a separate copy there because
|
|
40
|
+
// the Electron main process is bundled in a different runtime context).
|
|
41
|
+
|
|
42
|
+
import { execFileSync } from 'node:child_process';
|
|
43
|
+
import { existsSync, copyFileSync } from 'node:fs';
|
|
44
|
+
import path from 'node:path';
|
|
45
|
+
import { homedir, platform } from 'node:os';
|
|
46
|
+
|
|
47
|
+
export interface FailedMigration {
|
|
48
|
+
name: string;
|
|
49
|
+
/** Epoch milliseconds the migration was attempted. */
|
|
50
|
+
startedAt: number;
|
|
51
|
+
/** First 600 chars of the Prisma error log row, for display. */
|
|
52
|
+
errorExcerpt: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run a one-shot bun script against the given DB and parse its stdout
|
|
57
|
+
* as JSON. The script reads the DB path from `process.env.DBP` so we
|
|
58
|
+
* don't have to escape paths (which can contain spaces) through shell
|
|
59
|
+
* quoting. Throws if bun isn't usable or the script bails — we do NOT
|
|
60
|
+
* swallow these, since a broken recovery layer should surface its own
|
|
61
|
+
* defect rather than a misleading "database looks fine".
|
|
62
|
+
*/
|
|
63
|
+
function runBunScript<T>(bunPath: string, dbPath: string, script: string): T {
|
|
64
|
+
const out = execFileSync(bunPath, ['-e', script], {
|
|
65
|
+
env: { ...process.env, DBP: dbPath },
|
|
66
|
+
encoding: 'utf-8',
|
|
67
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
68
|
+
timeout: 5000,
|
|
69
|
+
});
|
|
70
|
+
const trimmed = out.trim();
|
|
71
|
+
if (!trimmed) {
|
|
72
|
+
throw new Error(`bun script returned empty output for ${dbPath}`);
|
|
73
|
+
}
|
|
74
|
+
return JSON.parse(trimmed) as T;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Return `_prisma_migrations` rows that are still failed (`finished_at`
|
|
79
|
+
* NULL) and not yet recovered (`rolled_back_at` NULL). Returns an empty
|
|
80
|
+
* array when the DB doesn't exist, the table hasn't been created, or no
|
|
81
|
+
* failures are present.
|
|
82
|
+
*/
|
|
83
|
+
export function detectFailedMigrations(bunPath: string, dbPath: string): FailedMigration[] {
|
|
84
|
+
if (!existsSync(dbPath)) return [];
|
|
85
|
+
|
|
86
|
+
const script = `
|
|
87
|
+
import { Database } from 'bun:sqlite';
|
|
88
|
+
try {
|
|
89
|
+
const db = new Database(process.env.DBP, { readonly: true });
|
|
90
|
+
const hasTable = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='_prisma_migrations'").get();
|
|
91
|
+
if (!hasTable) { console.log('[]'); process.exit(0); }
|
|
92
|
+
const rows = db
|
|
93
|
+
.query("SELECT migration_name as name, started_at as startedAt, substr(coalesce(logs, ''), 1, 600) as errorExcerpt FROM _prisma_migrations WHERE finished_at IS NULL AND rolled_back_at IS NULL ORDER BY started_at")
|
|
94
|
+
.all();
|
|
95
|
+
console.log(JSON.stringify(rows));
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.error(String(e?.stack || e));
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
return runBunScript<FailedMigration[]>(bunPath, dbPath, script);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Snapshot `shogo.db` (plus `-wal`/`-shm` sidecars if present) to a
|
|
107
|
+
* timestamped sibling file. Returns the backup path of the main DB.
|
|
108
|
+
* Uses plain copies (not SQLite's online backup) because the DB is not
|
|
109
|
+
* open at this point and `copyFileSync` works even on a DB SQLite would
|
|
110
|
+
* refuse to open. Throws on any I/O failure — the caller MUST treat that
|
|
111
|
+
* as "do not proceed with repair".
|
|
112
|
+
*/
|
|
113
|
+
export function backupDatabase(dbPath: string): string {
|
|
114
|
+
if (!existsSync(dbPath)) {
|
|
115
|
+
throw new Error(`Database does not exist at ${dbPath} — refusing to back up nothing`);
|
|
116
|
+
}
|
|
117
|
+
const dir = path.dirname(dbPath);
|
|
118
|
+
const base = path.basename(dbPath);
|
|
119
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
120
|
+
const backupPath = path.join(dir, `${base}.bak-${stamp}`);
|
|
121
|
+
copyFileSync(dbPath, backupPath);
|
|
122
|
+
|
|
123
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
124
|
+
const sidecar = `${dbPath}${suffix}`;
|
|
125
|
+
if (existsSync(sidecar)) {
|
|
126
|
+
copyFileSync(sidecar, `${backupPath}${suffix}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return backupPath;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Delete the named failed-migration rows from `_prisma_migrations`. This
|
|
134
|
+
* is the equivalent of `prisma migrate resolve --rolled-back <name>` for
|
|
135
|
+
* each, but without needing the schema-engine binary. Only deletes rows
|
|
136
|
+
* that are actually still failed (defends against a stale name list).
|
|
137
|
+
* Returns the number of rows deleted.
|
|
138
|
+
*
|
|
139
|
+
* The caller is expected to have run `backupDatabase()` first. After this,
|
|
140
|
+
* the next `prisma migrate deploy` (on the desktop app's next launch)
|
|
141
|
+
* re-attempts the migration.
|
|
142
|
+
*/
|
|
143
|
+
export function repairFailedMigrations(
|
|
144
|
+
bunPath: string,
|
|
145
|
+
dbPath: string,
|
|
146
|
+
migrationNames: string[],
|
|
147
|
+
): number {
|
|
148
|
+
if (migrationNames.length === 0) return 0;
|
|
149
|
+
if (!existsSync(dbPath)) {
|
|
150
|
+
throw new Error(`Cannot repair: database does not exist at ${dbPath}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const namesJson = JSON.stringify(migrationNames);
|
|
154
|
+
const script = `
|
|
155
|
+
import { Database } from 'bun:sqlite';
|
|
156
|
+
const names = ${namesJson};
|
|
157
|
+
if (!Array.isArray(names) || names.some(n => typeof n !== 'string')) {
|
|
158
|
+
console.error('Invalid migration name list');
|
|
159
|
+
process.exit(2);
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const db = new Database(process.env.DBP, { create: false, readwrite: true });
|
|
163
|
+
const placeholders = names.map(() => '?').join(',');
|
|
164
|
+
const stmt = db.prepare(
|
|
165
|
+
\`DELETE FROM _prisma_migrations WHERE migration_name IN (\${placeholders}) AND finished_at IS NULL AND rolled_back_at IS NULL\`
|
|
166
|
+
);
|
|
167
|
+
const result = stmt.run(...names);
|
|
168
|
+
console.log(JSON.stringify({ deleted: Number(result.changes) }));
|
|
169
|
+
} catch (e) {
|
|
170
|
+
console.error(String(e?.stack || e));
|
|
171
|
+
process.exit(3);
|
|
172
|
+
}
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
const out = runBunScript<{ deleted: number }>(bunPath, dbPath, script);
|
|
176
|
+
return out.deleted;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Orchestrator
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
export type DoctorStatus = 'healthy' | 'no-database' | 'repaired' | 'failed';
|
|
184
|
+
|
|
185
|
+
export interface DoctorResult {
|
|
186
|
+
status: DoctorStatus;
|
|
187
|
+
/** Migrations found in a failed state before repair. */
|
|
188
|
+
detected: FailedMigration[];
|
|
189
|
+
/** Path of the backup written before repair, if any. */
|
|
190
|
+
backupPath?: string;
|
|
191
|
+
/** Names of migration rows actually cleared. */
|
|
192
|
+
cleared: string[];
|
|
193
|
+
/** Migrations still failed after the repair attempt (should be empty on success). */
|
|
194
|
+
remaining: FailedMigration[];
|
|
195
|
+
/** Human-readable summary of what happened. */
|
|
196
|
+
message: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface DoctorOptions {
|
|
200
|
+
bunPath: string;
|
|
201
|
+
dbPath: string;
|
|
202
|
+
/** Skip the pre-repair backup (default: false). Discouraged. */
|
|
203
|
+
skipBackup?: boolean;
|
|
204
|
+
/** Logger for progress lines (default: no-op). */
|
|
205
|
+
log?: (line: string) => void;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Run the full safe repair sequence against a local SQLite DB:
|
|
210
|
+
* detect → backup → clear stuck ledger rows → re-detect. Idempotent:
|
|
211
|
+
* a healthy DB is a no-op. Never re-runs `migrate deploy` (that's the
|
|
212
|
+
* desktop app's job on next launch).
|
|
213
|
+
*/
|
|
214
|
+
export function runDatabaseDoctor(opts: DoctorOptions): DoctorResult {
|
|
215
|
+
const log = opts.log ?? (() => {});
|
|
216
|
+
const { bunPath, dbPath } = opts;
|
|
217
|
+
|
|
218
|
+
if (!existsSync(dbPath)) {
|
|
219
|
+
return {
|
|
220
|
+
status: 'no-database',
|
|
221
|
+
detected: [],
|
|
222
|
+
cleared: [],
|
|
223
|
+
remaining: [],
|
|
224
|
+
message: `No database found at ${dbPath}. Nothing to repair — launch Shogo once to create it.`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const detected = detectFailedMigrations(bunPath, dbPath);
|
|
229
|
+
if (detected.length === 0) {
|
|
230
|
+
return {
|
|
231
|
+
status: 'healthy',
|
|
232
|
+
detected: [],
|
|
233
|
+
cleared: [],
|
|
234
|
+
remaining: [],
|
|
235
|
+
message: 'No failed migrations detected — the local database looks healthy.',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
log(`Found ${detected.length} failed migration(s): ${detected.map((m) => m.name).join(', ')}`);
|
|
240
|
+
|
|
241
|
+
let backupPath: string | undefined;
|
|
242
|
+
if (!opts.skipBackup) {
|
|
243
|
+
backupPath = backupDatabase(dbPath);
|
|
244
|
+
log(`Backed up database to ${backupPath}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const names = detected.map((m) => m.name);
|
|
248
|
+
const deleted = repairFailedMigrations(bunPath, dbPath, names);
|
|
249
|
+
log(`Cleared ${deleted} failed migration row(s).`);
|
|
250
|
+
|
|
251
|
+
const remaining = detectFailedMigrations(bunPath, dbPath);
|
|
252
|
+
const status: DoctorStatus = remaining.length === 0 ? 'repaired' : 'failed';
|
|
253
|
+
const message =
|
|
254
|
+
status === 'repaired'
|
|
255
|
+
? 'Cleared the failed migration record. Relaunch Shogo to re-apply migrations cleanly.'
|
|
256
|
+
: `Repair incomplete — ${remaining.length} migration(s) still failed: ${remaining
|
|
257
|
+
.map((m) => m.name)
|
|
258
|
+
.join(', ')}.`;
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
status,
|
|
262
|
+
detected,
|
|
263
|
+
backupPath,
|
|
264
|
+
cleared: names.slice(0, deleted),
|
|
265
|
+
remaining,
|
|
266
|
+
message,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Path / binary resolution (for the standalone CLI)
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Resolve the desktop app's per-user data directory, mirroring
|
|
276
|
+
* Electron's `app.getPath('userData')` + the `data/` subdir used by
|
|
277
|
+
* apps/desktop/src/paths.ts. `productName` is "Shogo".
|
|
278
|
+
*
|
|
279
|
+
* macOS: ~/Library/Application Support/Shogo/data
|
|
280
|
+
* Windows: %APPDATA%/Shogo/data (Roaming)
|
|
281
|
+
* Linux: $XDG_CONFIG_HOME/Shogo/data (or ~/.config/Shogo/data)
|
|
282
|
+
*/
|
|
283
|
+
export function resolveDesktopDataDir(): string {
|
|
284
|
+
const home = homedir();
|
|
285
|
+
const plat = platform();
|
|
286
|
+
let appData: string;
|
|
287
|
+
if (plat === 'darwin') {
|
|
288
|
+
appData = path.join(home, 'Library', 'Application Support');
|
|
289
|
+
} else if (plat === 'win32') {
|
|
290
|
+
appData = process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming');
|
|
291
|
+
} else {
|
|
292
|
+
appData = process.env.XDG_CONFIG_HOME ?? path.join(home, '.config');
|
|
293
|
+
}
|
|
294
|
+
return path.join(appData, 'Shogo', 'data');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Default path of the desktop app's local SQLite database. */
|
|
298
|
+
export function resolveDesktopDbPath(): string {
|
|
299
|
+
return path.join(resolveDesktopDataDir(), 'shogo.db');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Candidate locations of the `bun` binary the installed desktop app
|
|
304
|
+
* ships in its `resources/bun/` directory. Best-effort and
|
|
305
|
+
* platform-specific; missing entries are filtered out by the caller.
|
|
306
|
+
*/
|
|
307
|
+
function bundledBunCandidates(): string[] {
|
|
308
|
+
const plat = platform();
|
|
309
|
+
const exe = plat === 'win32' ? 'bun.exe' : 'bun';
|
|
310
|
+
const home = homedir();
|
|
311
|
+
const candidates: string[] = [];
|
|
312
|
+
if (plat === 'darwin') {
|
|
313
|
+
candidates.push(
|
|
314
|
+
path.join('/Applications', 'Shogo.app', 'Contents', 'Resources', 'bun', exe),
|
|
315
|
+
path.join(home, 'Applications', 'Shogo.app', 'Contents', 'Resources', 'bun', exe),
|
|
316
|
+
);
|
|
317
|
+
} else if (plat === 'linux') {
|
|
318
|
+
candidates.push(
|
|
319
|
+
path.join('/opt', 'Shogo', 'resources', 'bun', exe),
|
|
320
|
+
path.join('/usr', 'lib', 'shogo', 'resources', 'bun', exe),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
// Windows installs under a version-stamped Squirrel dir
|
|
324
|
+
// (%LOCALAPPDATA%/shogo/app-<ver>/resources/bun/bun.exe) which we can't
|
|
325
|
+
// resolve without globbing; rely on --bun / PATH there.
|
|
326
|
+
return candidates;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** True if the given binary can be executed and reports a version. */
|
|
330
|
+
function bunIsUsable(bunPath: string): boolean {
|
|
331
|
+
try {
|
|
332
|
+
execFileSync(bunPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
|
333
|
+
return true;
|
|
334
|
+
} catch {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Resolve a usable `bun` binary for the repair scripts, in priority order:
|
|
341
|
+
* 1. explicit `override` (the `--bun` flag)
|
|
342
|
+
* 2. the bun currently running this CLI (tarball release)
|
|
343
|
+
* 3. the desktop app's bundled bun
|
|
344
|
+
* 4. `bun` on PATH
|
|
345
|
+
* Returns null if none are usable.
|
|
346
|
+
*/
|
|
347
|
+
export function resolveBunBinary(override?: string): string | null {
|
|
348
|
+
if (override) return bunIsUsable(override) ? override : null;
|
|
349
|
+
if (process.versions.bun && process.execPath) return process.execPath;
|
|
350
|
+
for (const candidate of bundledBunCandidates()) {
|
|
351
|
+
if (existsSync(candidate) && bunIsUsable(candidate)) return candidate;
|
|
352
|
+
}
|
|
353
|
+
if (bunIsUsable('bun')) return 'bun';
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Stable per-machine UUID stored at ~/.shogo/device-id.
|
|
5
|
+
*
|
|
6
|
+
* The cloud uses (workspaceId, deviceId) as a dedupe key — when the
|
|
7
|
+
* same machine re-runs `shogo login`, the previous device-tagged API
|
|
8
|
+
* key is auto-revoked so we don't accumulate "ghost" device rows in
|
|
9
|
+
* the dashboard. Stability across re-logins is what makes this work,
|
|
10
|
+
* so we persist the id at first generation rather than rolling a new
|
|
11
|
+
* one each time.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs';
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { DEVICE_ID_FILE, ensureHome } from './paths.ts';
|
|
16
|
+
|
|
17
|
+
export function getOrCreateDeviceId(): string {
|
|
18
|
+
if (existsSync(DEVICE_ID_FILE)) {
|
|
19
|
+
const value = readFileSync(DEVICE_ID_FILE, 'utf-8').trim();
|
|
20
|
+
if (value) return value;
|
|
21
|
+
}
|
|
22
|
+
ensureHome();
|
|
23
|
+
const id = randomUUID();
|
|
24
|
+
writeFileSync(DEVICE_ID_FILE, id, { mode: 0o600 });
|
|
25
|
+
chmodSync(DEVICE_ID_FILE, 0o600);
|
|
26
|
+
return id;
|
|
27
|
+
}
|