@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,123 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* `shogo` / `shogo chat` — launch the interactive coding agent.
|
|
5
|
+
*
|
|
6
|
+
* License boundary: this file (MIT) does NOT import `@shogo/agent-runtime`.
|
|
7
|
+
* It resolves the AGPL agent-runtime binary on disk and spawns it in
|
|
8
|
+
* interactive mode as a separate OS process (stdio inherited), passing the
|
|
9
|
+
* working directory and proxy-billing credentials via env. The agent loop
|
|
10
|
+
* runs in-process inside that AGPL binary — there is no HTTP runtime hop.
|
|
11
|
+
*
|
|
12
|
+
* Billing: the spawned runtime is pointed at the Shogo AI proxy
|
|
13
|
+
* (`AI_PROXY_URL` = `<cloud>/api/ai/v1`) authenticated with the logged-in
|
|
14
|
+
* workspace key (`AI_PROXY_TOKEN` = `shogo_sk_…`). The proxy accepts the key
|
|
15
|
+
* directly, so all LLM usage bills to the account with no token minting.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { resolve as resolvePath } from 'node:path';
|
|
19
|
+
import pc from 'picocolors';
|
|
20
|
+
import { resolveConfig } from '../lib/config.ts';
|
|
21
|
+
import { resolveRuntime, formatMissingRuntimeError, type ResolvedRuntime } from '../lib/runtime-resolver.ts';
|
|
22
|
+
|
|
23
|
+
export interface AgentFlags {
|
|
24
|
+
/** Headless one-shot prompt (`-p` / `--print`). `''` = read, but no value. */
|
|
25
|
+
print?: string;
|
|
26
|
+
/** Model id for new turns (`--model`). */
|
|
27
|
+
model?: string;
|
|
28
|
+
/** Working directory to operate in (`--cwd`). Defaults to $PWD. */
|
|
29
|
+
cwd?: string;
|
|
30
|
+
/** Override the agent-runtime binary path (`--runtime-bin`). */
|
|
31
|
+
runtimeBin?: string;
|
|
32
|
+
/** Disable the Ink TUI, use the plain readline renderer (`--no-tui`). */
|
|
33
|
+
noTui?: boolean;
|
|
34
|
+
/** API key override (else config/env). */
|
|
35
|
+
apiKey?: string;
|
|
36
|
+
/** Cloud URL override (else config/env). */
|
|
37
|
+
cloudUrl?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AgentSpawnPlan {
|
|
41
|
+
bin: string;
|
|
42
|
+
args: string[];
|
|
43
|
+
env: NodeJS.ProcessEnv;
|
|
44
|
+
cwd: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the spawn argv + env for the interactive runtime. Pure — no IO — so
|
|
49
|
+
* it is unit-testable.
|
|
50
|
+
*/
|
|
51
|
+
export function buildAgentSpawn(input: {
|
|
52
|
+
flags: AgentFlags;
|
|
53
|
+
config: { apiKey: string; cloudUrl: string };
|
|
54
|
+
runtime: ResolvedRuntime;
|
|
55
|
+
baseEnv?: NodeJS.ProcessEnv;
|
|
56
|
+
}): AgentSpawnPlan {
|
|
57
|
+
const { flags, config, runtime } = input;
|
|
58
|
+
const cwd = flags.cwd ? resolvePath(flags.cwd) : process.cwd();
|
|
59
|
+
|
|
60
|
+
const args: string[] = ['interactive'];
|
|
61
|
+
if (flags.print !== undefined) args.push('-p', flags.print);
|
|
62
|
+
if (flags.noTui) args.push('--no-tui');
|
|
63
|
+
|
|
64
|
+
const cloudUrl = config.cloudUrl.replace(/\/$/, '');
|
|
65
|
+
|
|
66
|
+
const env: NodeJS.ProcessEnv = {
|
|
67
|
+
...(input.baseEnv ?? process.env),
|
|
68
|
+
SHOGO_INTERACTIVE: '1',
|
|
69
|
+
SHOGO_INTERACTIVE_CWD: cwd,
|
|
70
|
+
// The runtime derives WORKSPACE_DIR from these — point them at the CWD so
|
|
71
|
+
// the agent reads/writes the user's actual directory.
|
|
72
|
+
PROJECT_DIR: cwd,
|
|
73
|
+
WORKSPACE_DIR: cwd,
|
|
74
|
+
SHOGO_API_URL: cloudUrl,
|
|
75
|
+
SHOGO_CLOUD_URL: cloudUrl,
|
|
76
|
+
SHOGO_API_KEY: config.apiKey,
|
|
77
|
+
// Proxy billing: route all LLM traffic through the Shogo proxy using the
|
|
78
|
+
// workspace key (accepted directly by the proxy).
|
|
79
|
+
AI_PROXY_URL: `${cloudUrl}/api/ai/v1`,
|
|
80
|
+
AI_PROXY_TOKEN: config.apiKey,
|
|
81
|
+
NODE_ENV: 'production',
|
|
82
|
+
};
|
|
83
|
+
if (flags.model) env.SHOGO_MODEL = flags.model;
|
|
84
|
+
if (flags.print !== undefined) env.SHOGO_PRINT_PROMPT = flags.print;
|
|
85
|
+
|
|
86
|
+
return { bin: runtime.path, args, env, cwd };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve config + runtime binary and exec the interactive agent. Resolves
|
|
91
|
+
* with the child's exit code.
|
|
92
|
+
*/
|
|
93
|
+
export async function runAgent(flags: AgentFlags = {}): Promise<void> {
|
|
94
|
+
// Gate on login — resolveConfig throws a `shogo login` hint when no key.
|
|
95
|
+
const cfg = resolveConfig({ apiKey: flags.apiKey, cloudUrl: flags.cloudUrl });
|
|
96
|
+
|
|
97
|
+
const runtime = resolveRuntime({ flag: flags.runtimeBin });
|
|
98
|
+
if (!runtime) {
|
|
99
|
+
console.error(pc.red(formatMissingRuntimeError({ flag: flags.runtimeBin })));
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const plan = buildAgentSpawn({
|
|
104
|
+
flags,
|
|
105
|
+
config: { apiKey: cfg.apiKey, cloudUrl: cfg.cloudUrl },
|
|
106
|
+
runtime,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const code = await new Promise<number>((resolveExit) => {
|
|
110
|
+
const child = spawn(plan.bin, plan.args, {
|
|
111
|
+
cwd: plan.cwd,
|
|
112
|
+
env: plan.env,
|
|
113
|
+
stdio: 'inherit',
|
|
114
|
+
});
|
|
115
|
+
child.on('error', (err) => {
|
|
116
|
+
console.error(pc.red(`✗ Failed to launch agent-runtime: ${err.message}`));
|
|
117
|
+
resolveExit(1);
|
|
118
|
+
});
|
|
119
|
+
child.on('exit', (exitCode) => resolveExit(exitCode ?? 0));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
process.exit(code);
|
|
123
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { loadConfig, saveConfig } from '../lib/config.ts';
|
|
5
|
+
import { CONFIG_FILE } from '../lib/paths.ts';
|
|
6
|
+
|
|
7
|
+
export async function runConfigShow(): Promise<void> {
|
|
8
|
+
const cfg = loadConfig();
|
|
9
|
+
if (Object.keys(cfg).length === 0) {
|
|
10
|
+
console.log(pc.dim('(no config — run `shogo login` or `shogo config set`)'));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const masked = { ...cfg, apiKey: cfg.apiKey ? `***${cfg.apiKey.slice(-4)}` : undefined };
|
|
14
|
+
console.log(pc.dim(`file: ${CONFIG_FILE}`));
|
|
15
|
+
console.log(JSON.stringify(masked, null, 2));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runConfigSet(key: string, value: string): Promise<void> {
|
|
19
|
+
const allowed = ['apiKey', 'cloudUrl', 'name', 'workerDir', 'port'] as const;
|
|
20
|
+
if (!allowed.includes(key as any)) {
|
|
21
|
+
console.error(pc.red(`Unknown key: ${key}`));
|
|
22
|
+
console.error(pc.dim(`Allowed: ${allowed.join(', ')}`));
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const cfg = loadConfig();
|
|
26
|
+
(cfg as any)[key] = key === 'port' ? parseInt(value, 10) : value;
|
|
27
|
+
saveConfig(cfg);
|
|
28
|
+
console.log(pc.green(`✓ ${key} set`));
|
|
29
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* `shogo doctor` — diagnose and repair a wedged local Shogo database.
|
|
5
|
+
*
|
|
6
|
+
* shogo doctor — detect + (with confirmation) repair
|
|
7
|
+
* shogo doctor --check — detect only, never mutate
|
|
8
|
+
* shogo doctor --yes — repair without the confirmation prompt
|
|
9
|
+
* shogo doctor --db <path> — target a specific shogo.db
|
|
10
|
+
* shogo doctor --bun <path> — use a specific bun binary
|
|
11
|
+
* shogo doctor --no-backup — skip the pre-repair backup (discouraged)
|
|
12
|
+
*
|
|
13
|
+
* Clears `_prisma_migrations` rows left in a failed (P3009) state so the
|
|
14
|
+
* desktop app can re-apply migrations on its next launch. Always backs up
|
|
15
|
+
* the database first unless `--no-backup` is passed.
|
|
16
|
+
*/
|
|
17
|
+
import { createInterface } from 'node:readline';
|
|
18
|
+
import pc from 'picocolors';
|
|
19
|
+
import {
|
|
20
|
+
detectFailedMigrations,
|
|
21
|
+
runDatabaseDoctor,
|
|
22
|
+
resolveDesktopDbPath,
|
|
23
|
+
resolveBunBinary,
|
|
24
|
+
type FailedMigration,
|
|
25
|
+
} from '../lib/db-doctor.ts';
|
|
26
|
+
|
|
27
|
+
export interface DoctorFlags {
|
|
28
|
+
check?: boolean;
|
|
29
|
+
yes?: boolean;
|
|
30
|
+
db?: string;
|
|
31
|
+
bun?: string;
|
|
32
|
+
backup?: boolean; // commander sets `backup: false` for --no-backup
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function printFailures(failures: FailedMigration[]): void {
|
|
36
|
+
for (const f of failures) {
|
|
37
|
+
const when = Number.isFinite(f.startedAt) ? new Date(f.startedAt).toISOString() : 'unknown time';
|
|
38
|
+
const firstLine = (f.errorExcerpt ?? '').split('\n')[0]?.trim();
|
|
39
|
+
console.log(` ${pc.yellow('•')} ${pc.bold(f.name)} ${pc.dim(`(attempted ${when})`)}`);
|
|
40
|
+
if (firstLine) console.log(` ${pc.dim(firstLine)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function confirm(question: string): Promise<boolean> {
|
|
45
|
+
// Non-interactive (CI, piped) stdin: refuse rather than hang.
|
|
46
|
+
if (!process.stdin.isTTY) {
|
|
47
|
+
console.log(pc.dim('(stdin is not a TTY — re-run with --yes to repair non-interactively)'));
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
51
|
+
try {
|
|
52
|
+
const answer = await new Promise<string>((resolve) => rl.question(question, resolve));
|
|
53
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
54
|
+
} finally {
|
|
55
|
+
rl.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function runDoctor(flags: DoctorFlags = {}): Promise<void> {
|
|
60
|
+
const dbPath = flags.db ?? resolveDesktopDbPath();
|
|
61
|
+
const bunPath = resolveBunBinary(flags.bun);
|
|
62
|
+
|
|
63
|
+
if (!bunPath) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
'Could not find a usable `bun` binary (needed to inspect the SQLite database).\n' +
|
|
66
|
+
' • Install Bun (https://bun.sh) so `bun` is on your PATH, or\n' +
|
|
67
|
+
' • pass --bun <path> pointing at the bun shipped inside the Shogo app\n' +
|
|
68
|
+
" (e.g. /Applications/Shogo.app/Contents/Resources/bun/bun on macOS).",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(pc.dim(`Database: ${dbPath}`));
|
|
73
|
+
console.log(pc.dim(`Bun: ${bunPath}`));
|
|
74
|
+
console.log();
|
|
75
|
+
|
|
76
|
+
const failures = detectFailedMigrations(bunPath, dbPath);
|
|
77
|
+
|
|
78
|
+
if (failures.length === 0) {
|
|
79
|
+
console.log(pc.green('✓ No failed migrations detected — your local database looks healthy.'));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log(pc.red(`✗ Found ${failures.length} failed migration(s):`));
|
|
84
|
+
printFailures(failures);
|
|
85
|
+
console.log();
|
|
86
|
+
|
|
87
|
+
if (flags.check) {
|
|
88
|
+
console.log(
|
|
89
|
+
pc.dim('Run `shogo doctor` (without --check) to back up the database and clear these records.'),
|
|
90
|
+
);
|
|
91
|
+
// Signal "needs repair" to scripts/CI without throwing a stack trace.
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!flags.yes) {
|
|
97
|
+
const backupNote =
|
|
98
|
+
flags.backup === false
|
|
99
|
+
? pc.red('This will NOT create a backup (--no-backup).')
|
|
100
|
+
: 'Your database will be backed up to a .bak-<timestamp> file first.';
|
|
101
|
+
console.log(backupNote);
|
|
102
|
+
const ok = await confirm(pc.bold('Clear these failed migration records and repair? [y/N] '));
|
|
103
|
+
if (!ok) {
|
|
104
|
+
console.log(pc.dim('Aborted — no changes made.'));
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
console.log();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const result = runDatabaseDoctor({
|
|
112
|
+
bunPath,
|
|
113
|
+
dbPath,
|
|
114
|
+
skipBackup: flags.backup === false,
|
|
115
|
+
log: (line) => console.log(pc.dim(` ${line}`)),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
console.log();
|
|
119
|
+
if (result.status === 'repaired') {
|
|
120
|
+
console.log(pc.green(`✓ ${result.message}`));
|
|
121
|
+
if (result.backupPath) console.log(pc.dim(` Backup: ${result.backupPath}`));
|
|
122
|
+
console.log(pc.bold('\n → Relaunch the Shogo app to finish applying migrations.'));
|
|
123
|
+
} else {
|
|
124
|
+
console.log(pc.red(`✗ ${result.message}`));
|
|
125
|
+
if (result.backupPath) console.log(pc.dim(` Backup: ${result.backupPath}`));
|
|
126
|
+
process.exitCode = 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* `shogo login` — pair this machine with Shogo Cloud.
|
|
5
|
+
*
|
|
6
|
+
* Two modes, in priority order:
|
|
7
|
+
*
|
|
8
|
+
* 1. `--api-key <key>` flag, or `SHOGO_API_KEY` env var
|
|
9
|
+
* → CI / headless mode. Validate against cloud, save, done.
|
|
10
|
+
*
|
|
11
|
+
* 2. Interactive (default)
|
|
12
|
+
* → Poll-based device flow (see lib/cloud-login.ts):
|
|
13
|
+
* POST /api/cli/login/start → state + authUrl
|
|
14
|
+
* open authUrl in browser → user approves on cloud
|
|
15
|
+
* GET /api/cli/login/poll → key once approved
|
|
16
|
+
*
|
|
17
|
+
* The minted key is written to ~/.shogo/config.json with mode 0600 so
|
|
18
|
+
* other local users can't read it. The same file is what
|
|
19
|
+
* `shogo worker start` reads on boot.
|
|
20
|
+
*
|
|
21
|
+
* The legacy "paste a shogo_sk_ key at the prompt" flow is still
|
|
22
|
+
* supported via `--api-key`; it just isn't the default any more.
|
|
23
|
+
*/
|
|
24
|
+
import pc from 'picocolors';
|
|
25
|
+
import { loadConfig, saveConfig } from '../lib/config.ts';
|
|
26
|
+
import { runCloudLogin, CloudLoginError } from '../lib/cloud-login.ts';
|
|
27
|
+
import { getOrCreateDeviceId } from '../lib/device-id.ts';
|
|
28
|
+
|
|
29
|
+
export interface LoginFlags {
|
|
30
|
+
apiKey?: string;
|
|
31
|
+
cloudUrl?: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
workspace?: string;
|
|
34
|
+
noBrowser?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_CLOUD_URL = 'https://studio.shogo.ai';
|
|
38
|
+
|
|
39
|
+
export async function runLogin(flags: LoginFlags): Promise<void> {
|
|
40
|
+
const cfg = loadConfig();
|
|
41
|
+
const cloudUrl = (flags.cloudUrl || cfg.cloudUrl || DEFAULT_CLOUD_URL).replace(/\/$/, '');
|
|
42
|
+
|
|
43
|
+
const escapeKey = flags.apiKey || process.env.SHOGO_API_KEY;
|
|
44
|
+
if (escapeKey) {
|
|
45
|
+
await loginWithApiKey({ key: escapeKey, cloudUrl, cfg, name: flags.name });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const deviceId = getOrCreateDeviceId();
|
|
50
|
+
let result;
|
|
51
|
+
try {
|
|
52
|
+
result = await runCloudLogin({
|
|
53
|
+
cloudUrl,
|
|
54
|
+
deviceId,
|
|
55
|
+
deviceName: flags.name,
|
|
56
|
+
workspaceId: flags.workspace,
|
|
57
|
+
openBrowser: !flags.noBrowser,
|
|
58
|
+
});
|
|
59
|
+
} catch (err: any) {
|
|
60
|
+
if (err instanceof CloudLoginError) {
|
|
61
|
+
console.error(pc.red(`✗ ${err.message}`));
|
|
62
|
+
if (err.kind === 'transport') {
|
|
63
|
+
console.error(pc.dim(` If your network blocks browsers, run with --api-key <key> instead.`));
|
|
64
|
+
}
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
cfg.apiKey = result.key;
|
|
71
|
+
cfg.cloudUrl = cloudUrl;
|
|
72
|
+
if (flags.name) cfg.name = flags.name;
|
|
73
|
+
saveConfig(cfg);
|
|
74
|
+
|
|
75
|
+
console.log('');
|
|
76
|
+
console.log(pc.green('✓ Signed in to Shogo Cloud'));
|
|
77
|
+
if (result.workspace) console.log(pc.dim(' workspace: ') + result.workspace);
|
|
78
|
+
if (result.email) console.log(pc.dim(' email: ') + result.email);
|
|
79
|
+
console.log(pc.dim(' saved to: ') + '~/.shogo/config.json');
|
|
80
|
+
console.log('');
|
|
81
|
+
console.log(pc.dim(' next: ') + 'shogo worker start');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface ApiKeyOpts {
|
|
85
|
+
key: string;
|
|
86
|
+
cloudUrl: string;
|
|
87
|
+
cfg: ReturnType<typeof loadConfig>;
|
|
88
|
+
name?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function loginWithApiKey({ key, cloudUrl, cfg, name }: ApiKeyOpts): Promise<void> {
|
|
92
|
+
if (!/^shogo_sk_/.test(key)) {
|
|
93
|
+
console.error(pc.red('✗ API key should start with "shogo_sk_". Copy it verbatim from the API Keys page.'));
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Mirror apps/api/src/routes/local-auth.ts: re-validate against cloud
|
|
98
|
+
// before persisting so the user gets immediate feedback on a bad key.
|
|
99
|
+
let validation: { valid?: boolean; error?: string; workspace?: { name?: string } | null; user?: { email?: string } | null };
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetch(`${cloudUrl}/api/api-keys/validate`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'content-type': 'application/json' },
|
|
104
|
+
body: JSON.stringify({ key }),
|
|
105
|
+
signal: AbortSignal.timeout(10_000),
|
|
106
|
+
});
|
|
107
|
+
validation = await res.json().catch(() => ({} as any)) as typeof validation;
|
|
108
|
+
if (!res.ok || !validation?.valid) {
|
|
109
|
+
console.error(pc.red(`✗ ${validation?.error || `Cloud rejected the key (HTTP ${res.status}).`}`));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
} catch (err: any) {
|
|
113
|
+
console.error(pc.red(`✗ Cannot reach Shogo Cloud at ${cloudUrl}: ${err?.message ?? err}`));
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
cfg.apiKey = key;
|
|
118
|
+
cfg.cloudUrl = cloudUrl;
|
|
119
|
+
if (name) cfg.name = name;
|
|
120
|
+
saveConfig(cfg);
|
|
121
|
+
|
|
122
|
+
console.log(pc.green('✓ API key saved to ~/.shogo/config.json'));
|
|
123
|
+
if (validation.workspace?.name) console.log(pc.dim(' workspace: ') + validation.workspace.name);
|
|
124
|
+
if (validation.user?.email) console.log(pc.dim(' email: ') + validation.user.email);
|
|
125
|
+
console.log(pc.dim(' next: ') + 'shogo worker start');
|
|
126
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { existsSync, statSync } from 'node:fs';
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
import { WORKER_LOG, WORKER_ERR } from '../lib/paths.ts';
|
|
7
|
+
|
|
8
|
+
export interface LogsFlags { follow?: boolean; err?: boolean }
|
|
9
|
+
|
|
10
|
+
export async function runLogs(flags: LogsFlags): Promise<void> {
|
|
11
|
+
const file = flags.err ? WORKER_ERR : WORKER_LOG;
|
|
12
|
+
if (!existsSync(file)) {
|
|
13
|
+
console.log(pc.dim('No logs yet.'));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const args = flags.follow ? ['-F', '-n', '200', file] : ['-n', '200', file];
|
|
17
|
+
const child = spawn('tail', args, { stdio: 'inherit' });
|
|
18
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
19
|
+
|
|
20
|
+
if (!flags.follow) {
|
|
21
|
+
const size = statSync(file).size;
|
|
22
|
+
if (size === 0) console.log(pc.dim('(log file is empty)'));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* `shogo project checkout <projectId> [--at <sha|name>] [--unshallow]`
|
|
5
|
+
*
|
|
6
|
+
* Rolls the worker's local copy of a project back (or forward) to a
|
|
7
|
+
* specific git checkpoint. By default this targets the project's
|
|
8
|
+
* current cloud HEAD — i.e. it's the worker-side equivalent of
|
|
9
|
+
* `git pull --rebase`.
|
|
10
|
+
*
|
|
11
|
+
* `--at` accepts:
|
|
12
|
+
* - A full or short SHA (anything `git rev-parse` can resolve).
|
|
13
|
+
* - A checkpoint name (resolved against
|
|
14
|
+
* `GET /api/projects/:projectId/checkpoints?limit=…`).
|
|
15
|
+
*
|
|
16
|
+
* `--unshallow` first converts the local repo from a shallow clone to
|
|
17
|
+
* a full clone — necessary when checking out a historical SHA that
|
|
18
|
+
* lives beyond the original `--depth=1` window.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync } from 'node:fs';
|
|
22
|
+
import { resolve } from 'node:path';
|
|
23
|
+
import pc from 'picocolors';
|
|
24
|
+
import { resolveConfig } from '../lib/config.ts';
|
|
25
|
+
import { projectDirFor } from '../lib/paths.ts';
|
|
26
|
+
import {
|
|
27
|
+
gitFetchAndReset,
|
|
28
|
+
gitFetchUnshallow,
|
|
29
|
+
isGitRepo,
|
|
30
|
+
runGit,
|
|
31
|
+
} from '../lib/git-cloner.ts';
|
|
32
|
+
|
|
33
|
+
export interface ProjectCheckoutFlags {
|
|
34
|
+
/** SHA or checkpoint name. Defaults to remote HEAD. */
|
|
35
|
+
at?: string;
|
|
36
|
+
/** Convert a shallow clone to a full one before checking out. */
|
|
37
|
+
unshallow?: boolean;
|
|
38
|
+
/** Local dir override. */
|
|
39
|
+
into?: string;
|
|
40
|
+
/** API key override. */
|
|
41
|
+
apiKey?: string;
|
|
42
|
+
/** Cloud URL override. */
|
|
43
|
+
cloudUrl?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runProjectCheckout(projectId: string, flags: ProjectCheckoutFlags): Promise<void> {
|
|
47
|
+
if (!projectId) throw new Error('projectId is required');
|
|
48
|
+
|
|
49
|
+
const cfg = resolveConfig({
|
|
50
|
+
apiKey: flags.apiKey,
|
|
51
|
+
cloudUrl: flags.cloudUrl,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const localDir = resolve(flags.into ?? projectDirFor(projectId, cfg.projectsDir));
|
|
55
|
+
if (!existsSync(localDir)) {
|
|
56
|
+
throw new Error(`Local project dir does not exist: ${localDir} — run \`shogo project pull\` first`);
|
|
57
|
+
}
|
|
58
|
+
if (!isGitRepo(localDir)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`${localDir} is not a git repo. \`shogo project checkout\` requires the git sync path; ` +
|
|
61
|
+
`run \`shogo project pull\` after installing git, or use \`shogo project pull --include\` for file-only restore.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log(pc.bold(`\nshogo project checkout ${pc.cyan(projectId)}`));
|
|
66
|
+
console.log(pc.dim(' cloud ') + cfg.cloudUrl);
|
|
67
|
+
console.log(pc.dim(' local ') + localDir);
|
|
68
|
+
if (flags.at) console.log(pc.dim(' at ') + flags.at);
|
|
69
|
+
console.log('');
|
|
70
|
+
|
|
71
|
+
if (flags.unshallow) {
|
|
72
|
+
console.log(pc.dim('Unshallowing repo (this may take a moment)...'));
|
|
73
|
+
await gitFetchUnshallow({
|
|
74
|
+
apiUrl: cfg.cloudUrl,
|
|
75
|
+
apiKey: cfg.apiKey,
|
|
76
|
+
projectId,
|
|
77
|
+
localDir,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!flags.at) {
|
|
82
|
+
// Default: fast-forward to remote HEAD.
|
|
83
|
+
const res = await gitFetchAndReset({
|
|
84
|
+
apiUrl: cfg.cloudUrl,
|
|
85
|
+
apiKey: cfg.apiKey,
|
|
86
|
+
projectId,
|
|
87
|
+
localDir,
|
|
88
|
+
});
|
|
89
|
+
console.log(pc.green(`✓ Reset to ${res.commitSha.slice(0, 8)} (remote HEAD)`));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Resolve --at: try as SHA first (cheapest), fall back to checkpoint
|
|
94
|
+
// name lookup against the cloud listing.
|
|
95
|
+
let targetSha: string;
|
|
96
|
+
try {
|
|
97
|
+
const head = await runGit(['rev-parse', '--verify', `${flags.at}^{commit}`], { cwd: localDir });
|
|
98
|
+
targetSha = head.stdout.trim();
|
|
99
|
+
} catch {
|
|
100
|
+
targetSha = await resolveCheckpointByName(cfg.cloudUrl, cfg.apiKey, projectId, flags.at);
|
|
101
|
+
console.log(pc.dim(` resolved checkpoint "${flags.at}" → ${targetSha.slice(0, 8)}`));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Fetch up to that SHA (covers the shallow-window case for users who
|
|
105
|
+
// didn't pass --unshallow but are reaching for a slightly older sha).
|
|
106
|
+
try {
|
|
107
|
+
await gitFetchAndReset({
|
|
108
|
+
apiUrl: cfg.cloudUrl,
|
|
109
|
+
apiKey: cfg.apiKey,
|
|
110
|
+
projectId,
|
|
111
|
+
localDir,
|
|
112
|
+
branch: targetSha,
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
// If we can't fetch directly to that sha (it's outside the shallow
|
|
116
|
+
// window), do a full unshallow and retry.
|
|
117
|
+
if (!flags.unshallow) {
|
|
118
|
+
console.log(pc.yellow(' fetch failed; unshallowing and retrying...'));
|
|
119
|
+
await gitFetchUnshallow({
|
|
120
|
+
apiUrl: cfg.cloudUrl,
|
|
121
|
+
apiKey: cfg.apiKey,
|
|
122
|
+
projectId,
|
|
123
|
+
localDir,
|
|
124
|
+
});
|
|
125
|
+
await gitFetchAndReset({
|
|
126
|
+
apiUrl: cfg.cloudUrl,
|
|
127
|
+
apiKey: cfg.apiKey,
|
|
128
|
+
projectId,
|
|
129
|
+
localDir,
|
|
130
|
+
branch: targetSha,
|
|
131
|
+
});
|
|
132
|
+
} else {
|
|
133
|
+
throw new Error(`Cannot reach commit ${targetSha} in local clone`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Final reset to the requested sha (FETCH_HEAD may differ if we
|
|
138
|
+
// fetched a branch and the user asked for a specific commit).
|
|
139
|
+
await runGit(['reset', '--hard', targetSha], { cwd: localDir });
|
|
140
|
+
console.log(pc.green(`✓ Checked out ${targetSha.slice(0, 8)}`));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface CheckpointListResponse {
|
|
144
|
+
ok: true;
|
|
145
|
+
checkpoints: Array<{ id: string; commitSha: string; name: string | null; commitMessage: string | null; createdAt: string }>;
|
|
146
|
+
hasMore: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve a checkpoint by user-facing name (case-insensitive).
|
|
151
|
+
* Strategy: ask the cloud for the most recent N checkpoints, pick the
|
|
152
|
+
* one whose `name` or first 8 chars of `commitMessage` matches.
|
|
153
|
+
*/
|
|
154
|
+
async function resolveCheckpointByName(
|
|
155
|
+
cloudUrl: string,
|
|
156
|
+
apiKey: string,
|
|
157
|
+
projectId: string,
|
|
158
|
+
needle: string,
|
|
159
|
+
): Promise<string> {
|
|
160
|
+
const url = `${cloudUrl.replace(/\/+$/, '')}/api/projects/${encodeURIComponent(projectId)}/checkpoints?limit=100`;
|
|
161
|
+
const res = await fetch(url, {
|
|
162
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
163
|
+
});
|
|
164
|
+
if (!res.ok) {
|
|
165
|
+
throw new Error(`Failed to list checkpoints: HTTP ${res.status}`);
|
|
166
|
+
}
|
|
167
|
+
const body = (await res.json()) as CheckpointListResponse;
|
|
168
|
+
const norm = needle.toLowerCase();
|
|
169
|
+
const match = body.checkpoints.find((cp) => {
|
|
170
|
+
if (cp.name && cp.name.toLowerCase() === norm) return true;
|
|
171
|
+
if (cp.commitSha.startsWith(needle)) return true;
|
|
172
|
+
if (cp.commitMessage && cp.commitMessage.toLowerCase().includes(norm)) return true;
|
|
173
|
+
return false;
|
|
174
|
+
});
|
|
175
|
+
if (!match) {
|
|
176
|
+
throw new Error(`No checkpoint matches "${needle}" (searched ${body.checkpoints.length} most recent)`);
|
|
177
|
+
}
|
|
178
|
+
return match.commitSha;
|
|
179
|
+
}
|