@stage-labs/metro 0.1.0-beta.189 → 0.1.0-beta.190

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.189",
3
+ "version": "0.1.0-beta.190",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.189",
3
+ "version": "0.1.0-beta.190",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -7,10 +7,12 @@ import { log } from '@metro-labs/core/log';
7
7
  import { writeSecure } from '@metro-labs/core/secure-fs';
8
8
  import { agentsDir } from '../agents/files.js';
9
9
  import { agentUser, CONFIG_FILE, forgetAgentUser, wantedAgentUser } from './user.js';
10
- import { listWorkspace, startMove } from './workspace.js';
10
+ import { listWorkspace, modeOf, startMove } from './workspace.js';
11
+ import { changeSchedule, listSchedules } from './schedules.js';
11
12
 
12
13
  const PATH = '/api/agent-user';
13
14
  const WORKSPACE = '/api/agent-user/workspace';
15
+ const SCHEDULES = '/api/schedules';
14
16
  const EXIT_DELAY_MS = 500;
15
17
  const NAMES_BODY_MAX = 128 * 1024;
16
18
 
@@ -59,26 +61,38 @@ async function workspaceAnswer(req: IncomingMessage, dir: string): Promise<unkno
59
61
  const user = agentUser(dir);
60
62
  if (user === null) throw new ApiError('switch Claude Code to its own user first', 409);
61
63
  if (req.method === 'GET') return { user: user.name, home: user.home, entries: listWorkspace(user) };
62
- const started = startMove(bodyField(await readJsonBody(req, NAMES_BODY_MAX), 'names'), user);
64
+ const body = await readJsonBody(req, NAMES_BODY_MAX);
65
+ const started = startMove(bodyField(body, 'names'), user, modeOf(bodyField(body, 'mode')));
63
66
  return { started, entries: listWorkspace(user) };
64
67
  }
65
68
 
69
+ async function schedulesAnswer(req: IncomingMessage, dir: string): Promise<unknown> {
70
+ const user = agentUser(dir);
71
+ if (req.method === 'GET') return { agentUser: user?.name ?? null, jobs: listSchedules(user) };
72
+ const body = await readJsonBody(req);
73
+ const id = bodyField(body, 'id');
74
+ if (typeof id !== 'string' || id === '') throw new ApiError('id is required', 400);
75
+ return { agentUser: user?.name ?? null, jobs: changeSchedule(id, bodyField(body, 'action'), user) };
76
+ }
77
+
78
+ async function switchAnswer(req: IncomingMessage, deps: AgentUserApiDeps, subject: string): Promise<unknown> {
79
+ const enabled = bodyField(await readJsonBody(req), 'enabled');
80
+ if (typeof enabled !== 'boolean') throw new ApiError('enabled must be true or false', 400);
81
+ const reason = unsupported((deps.host ?? realHost)());
82
+ if (enabled && reason !== null) throw new ApiError(reason, 400);
83
+ setWanted((deps.agents ?? agentsDir)(), enabled);
84
+ log.info({ enabled, subject }, 'agent-user-api: switched from the page; restarting');
85
+ setTimeout(deps.restart, EXIT_DELAY_MS).unref();
86
+ return { ...agentUserStatus(deps), restarting: true };
87
+ }
88
+
66
89
  export function handleAgentUserRequest(req: IncomingMessage, res: ServerResponse, deps: AgentUserApiDeps): boolean {
67
- const methods = { [PATH]: ['GET', 'POST'], [WORKSPACE]: ['GET', 'POST'] };
90
+ const methods = { [PATH]: ['GET', 'POST'], [WORKSPACE]: ['GET', 'POST'], [SCHEDULES]: ['GET', 'POST'] };
68
91
  return sessionRoute(req, res, { methods, admin: ['POST'], label: 'agent-user-api' }, async (session, path) => {
69
- if (path === WORKSPACE) {
70
- requireAdmin(session);
71
- return workspaceAnswer(req, (deps.agents ?? agentsDir)());
72
- }
73
- if (req.method === 'GET') return agentUserStatus(deps);
74
- const enabled = bodyField(await readJsonBody(req), 'enabled');
75
- if (typeof enabled !== 'boolean') throw new ApiError('enabled must be true or false', 400);
76
- const reason = unsupported((deps.host ?? realHost)());
77
- if (enabled && reason !== null) throw new ApiError(reason, 400);
78
92
  const dir = (deps.agents ?? agentsDir)();
79
- setWanted(dir, enabled);
80
- log.info({ enabled, subject: session.subject }, 'agent-user-api: switched from the page; restarting');
81
- setTimeout(deps.restart, EXIT_DELAY_MS).unref();
82
- return { ...agentUserStatus(deps), restarting: true };
93
+ if (path !== PATH) requireAdmin(session);
94
+ if (path === WORKSPACE) return workspaceAnswer(req, dir);
95
+ if (path === SCHEDULES) return schedulesAnswer(req, dir);
96
+ return req.method === 'GET' ? agentUserStatus(deps) : switchAnswer(req, deps, session.subject);
83
97
  });
84
98
  }
@@ -0,0 +1,34 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { errMsg, log } from '@metro-labs/core/log';
4
+ import { asUser, type AgentUser } from './user.js';
5
+
6
+ const REPAIR = [
7
+ 'from="$1"; to="$2"',
8
+ 'find "$to" -maxdepth 8 -name node_modules -prune -o -name .git -type f -print 2>/dev/null | while read -r f; do',
9
+ ' grep -q "^gitdir: $from/" "$f" && sed -i "s#^gitdir: $from/#gitdir: $to/#" "$f" && echo "$f"',
10
+ 'done',
11
+ 'find "$to" -maxdepth 10 -name node_modules -prune -o -path "*/.git/worktrees/*/gitdir" -type f -print 2>/dev/null | while read -r g; do',
12
+ ' grep -q "^$from/" "$g" && sed -i "s#^$from/#$to/#" "$g" && echo "$g"',
13
+ 'done',
14
+ ].join('\n');
15
+
16
+ export function repairGitLinks(user: AgentUser, from = homedir()): Promise<number> {
17
+ const [file, args] = asUser(user, 'sh', ['-c', REPAIR, 'metro', from, user.home]);
18
+ return new Promise((resolve) => {
19
+ const child = spawn(file, args, { stdio: ['ignore', 'pipe', 'ignore'] });
20
+ let out = '';
21
+ child.stdout.on('data', (chunk: Buffer) => {
22
+ out += chunk.toString('utf8');
23
+ });
24
+ child.on('error', (err) => {
25
+ log.warn({ err: errMsg(err) }, 'agent-user: could not repair git links');
26
+ resolve(0);
27
+ });
28
+ child.on('close', () => {
29
+ const fixed = out.split('\n').filter((line) => line !== '').length;
30
+ if (fixed > 0) log.info({ fixed }, "agent-user: repaired git links that still pointed into root's home");
31
+ resolve(fixed);
32
+ });
33
+ });
34
+ }
@@ -8,6 +8,7 @@ import { agentsDir } from '../agents/files.js';
8
8
  import { copyIntoHome } from './home-fs.js';
9
9
  import { agentMarketplaceDir, agentUser, asUser, claudeBin, forgetAgentUser, lookupUser, wantedAgentUser, type AgentUser } from './user.js';
10
10
  import { watchAgentView } from './view.js';
11
+ import { repairGitLinks } from './git-links.js';
11
12
 
12
13
  const INSTALL_MS = 10 * 60_000;
13
14
  const INSTALLER = 'curl -fsSL https://claude.ai/install.sh | bash';
@@ -185,6 +186,9 @@ export async function provisionAgentUser(dir = agentsDir(), env: NodeJS.ProcessE
185
186
  await installClaude(user);
186
187
  copyMarketplace(user, env);
187
188
  watchAgentView();
189
+ repairGitLinks(user).catch((err: unknown) => {
190
+ log.warn({ err: errMsg(err) }, 'agent-user: could not repair git links');
191
+ });
188
192
  return 'ready';
189
193
  } catch (err) {
190
194
  log.error({ user: name, err: errMsg(err) }, 'agent-user: could not prepare the agent user; the Claude session stays stopped');
@@ -0,0 +1,230 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { ApiError } from '@metro-labs/http/api-error';
7
+ import { isRecord } from '@metro-labs/core/is-record';
8
+ import { log } from '@metro-labs/core/log';
9
+ import type { AgentUser } from './user.js';
10
+
11
+ const UNIT_DIR = '/etc/systemd/system';
12
+ const DROP_IN = '10-metro-agent.conf';
13
+ const OWN_UNITS = /^metro(-claude-\d+)?\.(service|scope)$/;
14
+
15
+ export type JobKind = 'timer' | 'cron-root' | 'cron-agent';
16
+
17
+ export interface ScheduledJob {
18
+ id: string;
19
+ kind: JobKind;
20
+ name: string;
21
+ schedule: string;
22
+ command: string;
23
+ runsAs: string;
24
+ next: string | null;
25
+ last: string | null;
26
+ lastResult: string | null;
27
+ usesRoot: boolean;
28
+ converted: boolean;
29
+ }
30
+
31
+ export interface Runner {
32
+ run: (file: string, args: string[], input?: string) => { status: number | null; stdout: string };
33
+ }
34
+
35
+ const realRunner: Runner = {
36
+ run: (file, args, input) => {
37
+ const done = spawnSync(file, args, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'ignore'], timeout: 20_000 });
38
+ return { status: done.error === undefined ? done.status : null, stdout: done.stdout ?? '' };
39
+ },
40
+ };
41
+
42
+ const ROOT_RE = /(^|[\s="'=:])\/root(?=\/|$|[\s"';:])/;
43
+ export const usesRootHome = (text: string): boolean => ROOT_RE.test(text);
44
+ export const rehome = (text: string, home: string): string => text.replace(/(^|[\s="'=:])\/root(?=\/|$|[\s"';:])/g, `$1${home}`);
45
+
46
+ const stamp = (micros: unknown): string | null =>
47
+ typeof micros === 'number' && micros > 0 ? new Date(micros / 1000).toISOString() : null;
48
+
49
+ function showProps(runner: Runner, unit: string, props: string[]): Record<string, string> {
50
+ const out = runner.run('systemctl', ['show', unit, '--no-pager', ...props.map((p) => `-p${p}`)]).stdout;
51
+ const found: Record<string, string> = {};
52
+ for (const line of out.split('\n')) {
53
+ const at = line.indexOf('=');
54
+ if (at > 0) found[line.slice(0, at)] = line.slice(at + 1);
55
+ }
56
+ return found;
57
+ }
58
+
59
+ export function execArgv(execStart: string): string[] {
60
+ const m = /argv\[\]=(.*?) ;/.exec(execStart);
61
+ return m?.[1] === undefined ? [] : m[1].split(' ').filter((a) => a !== '');
62
+ }
63
+
64
+ export const calendarOf = (timers: string): string =>
65
+ /OnCalendar=([^;]*?) ;/.exec(timers)?.[1]?.trim() ?? /(OnUnitActiveUSec|OnBootUSec|OnActiveUSec)=([^;]*?) ;/.exec(timers)?.[0]?.replace(/ ;$/, '') ?? '';
66
+
67
+ function listTimers(runner: Runner): Record<string, unknown>[] {
68
+ try {
69
+ const raw: unknown = JSON.parse(runner.run('systemctl', ['list-timers', '--all', '--output=json', '--no-pager']).stdout);
70
+ return Array.isArray(raw) ? raw.filter(isRecord) : [];
71
+ } catch {
72
+ return [];
73
+ }
74
+ }
75
+
76
+ const text = (v: unknown): string => (typeof v === 'string' ? v : '');
77
+ const orRoot = (user: string | undefined): string => (user === undefined || user === '' ? 'root' : user);
78
+
79
+ function timerJob(runner: Runner, t: Record<string, unknown>): ScheduledJob | null {
80
+ const unit = text(t.unit);
81
+ const service = text(t.activates);
82
+ if (unit === '' || service === '' || OWN_UNITS.test(service)) return null;
83
+ const timer = showProps(runner, unit, ['FragmentPath', 'TimersCalendar', 'TimersMonotonic']);
84
+ if (!text(timer.FragmentPath).startsWith(`${UNIT_DIR}/`)) return null;
85
+ const svc = showProps(runner, service, ['ExecStart', 'User', 'Result', 'DropInPaths', 'Environment', 'WorkingDirectory']);
86
+ const command = execArgv(text(svc.ExecStart)).join(' ');
87
+ return {
88
+ id: `timer:${unit}`,
89
+ kind: 'timer',
90
+ name: unit.replace(/\.timer$/, ''),
91
+ schedule: calendarOf(`${text(timer.TimersCalendar)} ${text(timer.TimersMonotonic)}`),
92
+ command,
93
+ runsAs: orRoot(svc.User),
94
+ next: stamp(t.next),
95
+ last: stamp(t.last),
96
+ lastResult: svc.Result ?? null,
97
+ usesRoot: usesRootHome(`${command} ${text(svc.Environment)} ${text(svc.WorkingDirectory)}`),
98
+ converted: text(svc.DropInPaths).includes(DROP_IN),
99
+ };
100
+ }
101
+
102
+ const timerJobs = (runner: Runner): ScheduledJob[] =>
103
+ listTimers(runner).flatMap((t) => {
104
+ const job = timerJob(runner, t);
105
+ return job === null ? [] : [job];
106
+ });
107
+
108
+ export interface CronLine {
109
+ schedule: string;
110
+ command: string;
111
+ }
112
+
113
+ export function parseCronLine(line: string): CronLine | null {
114
+ const text = line.trim();
115
+ if (text === '' || text.startsWith('#') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(text)) return null;
116
+ if (text.startsWith('@')) {
117
+ const [at = '', ...rest] = text.split(/\s+/);
118
+ return rest.length === 0 ? null : { schedule: at, command: rest.join(' ') };
119
+ }
120
+ const parts = text.split(/\s+/);
121
+ if (parts.length < 6) return null;
122
+ return { schedule: parts.slice(0, 5).join(' '), command: parts.slice(5).join(' ') };
123
+ }
124
+
125
+ const lineId = (kind: JobKind, line: string): string => `${kind}:${createHash('sha256').update(line.trim()).digest('hex').slice(0, 16)}`;
126
+
127
+ function crontabOf(runner: Runner, user: string): string[] {
128
+ const out = runner.run('crontab', ['-l', '-u', user]);
129
+ return out.status === 0 ? out.stdout.split('\n') : [];
130
+ }
131
+
132
+ function cronJobs(runner: Runner, user: string, kind: JobKind): ScheduledJob[] {
133
+ return crontabOf(runner, user).flatMap((line): ScheduledJob[] => {
134
+ const parsed = parseCronLine(line);
135
+ if (parsed === null) return [];
136
+ return [{
137
+ id: lineId(kind, line),
138
+ kind,
139
+ name: parsed.command.split(/\s+/)[0]?.split('/').pop() ?? 'cron',
140
+ schedule: parsed.schedule,
141
+ command: parsed.command,
142
+ runsAs: user,
143
+ next: null,
144
+ last: null,
145
+ lastResult: null,
146
+ usesRoot: usesRootHome(parsed.command),
147
+ converted: false,
148
+ }];
149
+ });
150
+ }
151
+
152
+ export function listSchedules(user: AgentUser | null, runner: Runner = realRunner): ScheduledJob[] {
153
+ return [...timerJobs(runner), ...cronJobs(runner, 'root', 'cron-root'), ...(user === null ? [] : cronJobs(runner, user.name, 'cron-agent'))];
154
+ }
155
+
156
+ const quote = (arg: string): string => `"${arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
157
+
158
+ export function dropInText(user: AgentUser, argv: string[], environment: string): string {
159
+ const env = environment.split(' ').filter((pair) => pair.includes('=') && !pair.startsWith('HOME=') && !pair.startsWith('PATH='));
160
+ return [
161
+ '[Service]',
162
+ `User=${user.name}`,
163
+ `Group=${String(user.gid)}`,
164
+ `WorkingDirectory=${user.home}`,
165
+ `Environment=HOME=${user.home}`,
166
+ `Environment=PATH=${user.home}/.local/bin:/usr/local/bin:/usr/bin:/bin`,
167
+ ...env.map((pair) => `Environment=${quote(rehome(pair, user.home))}`),
168
+ 'ExecStart=',
169
+ `ExecStart=${argv.map((a) => quote(rehome(a, user.home))).join(' ')}`,
170
+ '',
171
+ ].join('\n');
172
+ }
173
+
174
+ function convertTimer(job: ScheduledJob, user: AgentUser, runner: Runner): void {
175
+ const unit = job.id.slice('timer:'.length);
176
+ const service = showProps(runner, unit, ['Unit']).Unit ?? unit.replace(/\.timer$/, '.service');
177
+ const svc = showProps(runner, service, ['ExecStart', 'Environment']);
178
+ const argv = execArgv(svc.ExecStart ?? '');
179
+ if (argv.length === 0) throw new ApiError('that timer runs no command metro can read', 400);
180
+ const missing = argv.map((a) => rehome(a, user.home)).find((a) => a.startsWith(`${user.home}/`) && !existsSync(a));
181
+ if (missing !== undefined) throw new ApiError(`${missing} does not exist; move it to the agent first`, 409);
182
+ const dir = join(UNIT_DIR, `${service}.d`);
183
+ mkdirSync(dir, { recursive: true });
184
+ writeFileSync(join(dir, DROP_IN), dropInText(user, argv, svc.Environment ?? ''), { mode: 0o644 });
185
+ runner.run('systemctl', ['daemon-reload']);
186
+ log.info({ unit, service, user: user.name }, 'schedules: a timer now runs as the agent user');
187
+ }
188
+
189
+ function restoreTimer(job: ScheduledJob, runner: Runner): void {
190
+ const unit = job.id.slice('timer:'.length);
191
+ const service = showProps(runner, unit, ['Unit']).Unit ?? unit.replace(/\.timer$/, '.service');
192
+ rmSync(join(UNIT_DIR, `${service}.d`, DROP_IN), { force: true });
193
+ runner.run('systemctl', ['daemon-reload']);
194
+ log.info({ unit, service }, 'schedules: a timer runs as root again');
195
+ }
196
+
197
+ function convertCron(job: ScheduledJob, user: AgentUser, backups: string, runner: Runner): void {
198
+ const lines = crontabOf(runner, 'root');
199
+ const line = lines.find((l) => lineId('cron-root', l) === job.id);
200
+ if (line === undefined) throw new ApiError('that cron line is gone', 404);
201
+ mkdirSync(backups, { recursive: true });
202
+ writeFileSync(join(backups, `crontab-root.${new Date().toISOString().replace(/[:.]/g, '-')}.bak`), lines.join('\n'), { mode: 0o600 });
203
+ const agentLines = crontabOf(runner, user.name).filter((l) => l !== '');
204
+ const moved = runner.run('crontab', ['-u', user.name, '-'], `${[...agentLines, rehome(line, user.home)].join('\n')}\n`);
205
+ if (moved.status !== 0) throw new ApiError("the agent's crontab refused the line", 500);
206
+ const kept = lines.filter((l) => l !== line && l !== '');
207
+ runner.run('crontab', ['-u', 'root', '-'], `${kept.join('\n')}\n`);
208
+ log.info({ user: user.name }, "schedules: a root cron line moved to the agent's crontab");
209
+ }
210
+
211
+ function toRoot(job: ScheduledJob, runner: Runner): void {
212
+ if (job.kind !== 'timer' || !job.converted) throw new ApiError('only a timer metro switched can go back to root', 400);
213
+ restoreTimer(job, runner);
214
+ }
215
+
216
+ function toAgent(job: ScheduledJob, user: AgentUser | null, backups: string, runner: Runner): void {
217
+ if (user === null) throw new ApiError('switch Claude Code to its own user first', 409);
218
+ if (job.kind === 'timer') convertTimer(job, user, runner);
219
+ else if (job.kind === 'cron-root') convertCron(job, user, backups, runner);
220
+ else throw new ApiError('that job already runs as the agent', 400);
221
+ }
222
+
223
+ export function changeSchedule(id: string, action: unknown, user: AgentUser | null, backups = join(homedir(), '.metro', 'agents'), runner: Runner = realRunner): ScheduledJob[] {
224
+ if (action !== 'agent' && action !== 'root') throw new ApiError('action must be agent or root', 400);
225
+ const job = listSchedules(user, runner).find((j) => j.id === id);
226
+ if (job === undefined) throw new ApiError('no such scheduled job', 404);
227
+ if (action === 'root') toRoot(job, runner);
228
+ else toAgent(job, user, backups, runner);
229
+ return listSchedules(user, runner);
230
+ }
@@ -1,21 +1,39 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
- import { lstatSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
2
+ import { existsSync, lstatSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { dirname, join } from 'node:path';
5
5
  import { ApiError } from '@metro-labs/http/api-error';
6
6
  import { errMsg, log } from '@metro-labs/core/log';
7
- import type { AgentUser } from './user.js';
7
+ import { repairGitLinks } from './git-links.js';
8
+ import { asUser, type AgentUser } from './user.js';
8
9
 
9
10
  const SKIPPED = new Set(['snap', 'lost+found']);
11
+ const SAFE_HIDDEN = [
12
+ '.cache/huggingface/hub',
13
+ '.cache/whisper',
14
+ '.cache/torch',
15
+ '.cache/ms-playwright',
16
+ '.venv',
17
+ '.venvs',
18
+ '.virtualenvs',
19
+ '.pyenv',
20
+ '.bun',
21
+ '.nvm',
22
+ '.cargo',
23
+ '.rustup',
24
+ '.deno',
25
+ ];
10
26
  const SIZE_MS = 2_000;
11
27
  const SIZES_BUDGET_MS = 6_000;
12
28
  const NAMES_MAX = 1_000;
13
29
 
14
30
  export type EntryState = 'here' | 'waiting' | 'copying' | 'moved' | 'failed';
31
+ export type MoveMode = 'copy' | 'move';
15
32
 
16
33
  export interface WorkspaceEntry {
17
34
  name: string;
18
35
  kind: 'folder' | 'file' | 'link';
36
+ hidden: boolean;
19
37
  bytes: number | null;
20
38
  state: EntryState;
21
39
  error: string | null;
@@ -29,8 +47,6 @@ interface Job {
29
47
  const jobs = new Map<string, Job>();
30
48
  let queue: Promise<void> = Promise.resolve();
31
49
 
32
- const offered = (name: string): boolean => !name.startsWith('.') && !SKIPPED.has(name) && !name.includes('/');
33
-
34
50
  function kindOf(path: string): WorkspaceEntry['kind'] | null {
35
51
  try {
36
52
  const stat = lstatSync(path);
@@ -42,13 +58,19 @@ function kindOf(path: string): WorkspaceEntry['kind'] | null {
42
58
  }
43
59
  }
44
60
 
61
+ const present = (path: string): boolean => kindOf(path) !== null;
62
+
45
63
  function sizeOf(path: string): number | null {
46
64
  const run = spawnSync('du', ['-sk', path], { encoding: 'utf8', timeout: SIZE_MS, stdio: ['ignore', 'pipe', 'ignore'] });
47
65
  const kb = Number(run.stdout.split('\t')[0]);
48
66
  return run.status === 0 && Number.isFinite(kb) ? kb * 1024 : null;
49
67
  }
50
68
 
51
- const present = (path: string): boolean => kindOf(path) !== null;
69
+ export function candidates(from = homedir()): string[] {
70
+ const visible = readdirSync(from).filter((name) => !name.startsWith('.') && !SKIPPED.has(name));
71
+ const hidden = SAFE_HIDDEN.filter((name) => kindOf(join(from, name)) === 'folder');
72
+ return [...visible.sort((a, b) => a.localeCompare(b)), ...hidden];
73
+ }
52
74
 
53
75
  function stateOf(name: string, user: AgentUser): Pick<WorkspaceEntry, 'state' | 'error'> {
54
76
  const job = jobs.get(name);
@@ -58,16 +80,13 @@ function stateOf(name: string, user: AgentUser): Pick<WorkspaceEntry, 'state' |
58
80
 
59
81
  export function listWorkspace(user: AgentUser, from = homedir(), now = Date.now): WorkspaceEntry[] {
60
82
  const until = now() + SIZES_BUDGET_MS;
61
- return readdirSync(from)
62
- .filter(offered)
63
- .sort((a, b) => a.localeCompare(b))
64
- .flatMap((name): WorkspaceEntry[] => {
65
- const path = join(from, name);
66
- const kind = kindOf(path);
67
- if (kind === null) return [];
68
- const bytes = kind === 'file' ? lstatSync(path).size : kind === 'folder' && now() < until ? sizeOf(path) : null;
69
- return [{ name, kind, bytes, ...stateOf(name, user) }];
70
- });
83
+ return candidates(from).flatMap((name): WorkspaceEntry[] => {
84
+ const path = join(from, name);
85
+ const kind = kindOf(path);
86
+ if (kind === null) return [];
87
+ const bytes = kind === 'file' ? lstatSync(path).size : kind === 'folder' && now() < until ? sizeOf(path) : null;
88
+ return [{ name, kind, hidden: name.startsWith('.'), bytes, ...stateOf(name, user) }];
89
+ });
71
90
  }
72
91
 
73
92
  function run(file: string, args: string[]): Promise<void> {
@@ -85,28 +104,58 @@ function run(file: string, args: string[]): Promise<void> {
85
104
  });
86
105
  }
87
106
 
88
- async function moveOne(name: string, user: AgentUser, from: string): Promise<void> {
107
+ function makeParents(user: AgentUser, target: string): void {
108
+ const parent = dirname(target);
109
+ if (parent === user.home || existsSync(parent)) return;
110
+ const [file, args] = asUser(user, 'mkdir', ['-p', '--', parent]);
111
+ spawnSync(file, args, { stdio: 'ignore' });
112
+ }
113
+
114
+ const sameDisk = (a: string, b: string): boolean => statSync(a).dev === statSync(b).dev;
115
+
116
+ async function putBack(staged: string, source: string): Promise<void> {
117
+ if (present(staged) && !present(source)) await run('mv', ['-T', '--', staged, source]);
118
+ }
119
+
120
+ async function place(name: string, user: AgentUser, from: string, mode: MoveMode): Promise<void> {
121
+ const source = join(from, name);
122
+ const target = join(user.home, name);
89
123
  const staging = mkdtempSync(join(user.home, '..', '.metro-move-'));
124
+ const staged = join(staging, 'entry');
90
125
  try {
91
- const staged = join(staging, name);
92
- await run('cp', ['-a', '--', join(from, name), staged]);
126
+ if (mode === 'move') await run('mv', ['-T', '--', source, staged]);
127
+ else await run('cp', ['-a', '--', source, staged]);
93
128
  await run('chown', ['-hR', `${String(user.uid)}:${String(user.gid)}`, staged]);
94
- const target = join(user.home, name);
129
+ makeParents(user, target);
95
130
  if (present(target)) throw new Error(`${target} already exists; move or remove it first`);
96
131
  await run('mv', ['-T', '--', staged, target]);
97
- jobs.delete(name);
98
- log.info({ name, user: user.name }, 'agent-user: moved a folder from root\'s home to the agent user');
132
+ } catch (err) {
133
+ if (mode === 'move') await putBack(staged, source);
134
+ throw err;
99
135
  } finally {
100
136
  rmSync(staging, { recursive: true, force: true });
101
137
  }
102
138
  }
103
139
 
140
+ async function moveOne(name: string, user: AgentUser, from: string, mode: MoveMode): Promise<void> {
141
+ await place(name, user, from, mode);
142
+ jobs.delete(name);
143
+ await repairGitLinks(user, from);
144
+ log.info({ name, mode, user: user.name }, "agent-user: an entry from root's home now belongs to the agent user");
145
+ }
146
+
104
147
  function wantedNames(names: unknown): string[] {
105
148
  if (!Array.isArray(names) || names.length === 0 || names.length > NAMES_MAX || !names.every((n) => typeof n === 'string'))
106
149
  throw new ApiError('names must be a list of entries from the list', 400);
107
150
  return [...new Set(names)];
108
151
  }
109
152
 
153
+ export function modeOf(raw: unknown): MoveMode {
154
+ if (raw === undefined || raw === 'copy') return 'copy';
155
+ if (raw === 'move') return 'move';
156
+ throw new ApiError('mode must be copy or move', 400);
157
+ }
158
+
110
159
  function assertMovable(name: string, listed: ReadonlySet<string>, user: AgentUser): void {
111
160
  if (!listed.has(name)) throw new ApiError(`${name} is not an entry metro offers to move`, 400);
112
161
  const job = jobs.get(name)?.state;
@@ -114,17 +163,18 @@ function assertMovable(name: string, listed: ReadonlySet<string>, user: AgentUse
114
163
  if (present(join(user.home, name))) throw new ApiError(`${name} already exists in ${user.home}`, 409);
115
164
  }
116
165
 
117
- export function startMove(names: unknown, user: AgentUser, from = homedir()): string[] {
166
+ export function startMove(names: unknown, user: AgentUser, mode: MoveMode = 'copy', from = homedir()): string[] {
118
167
  const wanted = wantedNames(names);
119
- const listed = new Set(readdirSync(from).filter(offered));
168
+ const listed = new Set(candidates(from));
120
169
  for (const name of wanted) assertMovable(name, listed, user);
170
+ if (mode === 'move' && !sameDisk(from, dirname(user.home))) throw new ApiError(`${from} is on another disk than ${user.home}; copy instead`, 400);
121
171
  for (const name of wanted) {
122
172
  jobs.set(name, { state: 'waiting', error: null });
123
173
  queue = queue.then(async () => {
124
174
  jobs.set(name, { state: 'copying', error: null });
125
- await moveOne(name, user, from).catch((err: unknown) => {
175
+ await moveOne(name, user, from, mode).catch((err: unknown) => {
126
176
  jobs.set(name, { state: 'failed', error: errMsg(err) });
127
- log.warn({ name, err: errMsg(err) }, 'agent-user: could not move a folder to the agent user');
177
+ log.warn({ name, err: errMsg(err) }, 'agent-user: could not move an entry to the agent user');
128
178
  });
129
179
  });
130
180
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.189"
2
+ "version": "0.1.0-beta.190"
3
3
  }