@stndrds/cli 1.0.0-alpha.292 → 1.0.0-alpha.293
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/README.md +34 -0
- package/dist/bin.mjs +934 -127
- package/package.json +2 -2
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Standards CLI
|
|
2
|
+
|
|
3
|
+
The Standards CLI connects a terminal to a Standards instance. See the public
|
|
4
|
+
[CLI guide](../../content/docs/07-cli.mdx) for installation and the full command surface.
|
|
5
|
+
|
|
6
|
+
## Run this computer as a paired device
|
|
7
|
+
|
|
8
|
+
Remote device execution is an optional server capability. Once an operator has enabled it,
|
|
9
|
+
create a named profile, pair the current computer, and keep the foreground worker running:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
standards login --name production --url https://standards.example/v1 --key "$STANDARDS_API_KEY"
|
|
13
|
+
standards device pair --name "Build Mac"
|
|
14
|
+
standards device serve
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The worker supports macOS and Linux and makes outbound HTTPS requests only. It executes each
|
|
18
|
+
approved command in the foreground using the user's local account and permissions. There is no
|
|
19
|
+
privilege escalation, background service installation, PTY, file transfer, or GUI control.
|
|
20
|
+
|
|
21
|
+
Commands time out after 10 minutes by default and may not exceed 60 minutes. Standard output and
|
|
22
|
+
standard error are each limited to 30,000 characters. Stop the worker with `Ctrl+C`; inspect it
|
|
23
|
+
with `standards device status`; revoke its credential with `standards device revoke`.
|
|
24
|
+
|
|
25
|
+
Interactive harnesses are outside the V0 contract. Invoke installed tools in their
|
|
26
|
+
non-interactive mode, for example:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
codex exec --json "Run the repository checks"
|
|
30
|
+
claude -p "Summarize the failing tests"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Every agent command targeting a device requires explicit user approval. Calls to `exec_command`
|
|
34
|
+
without a `deviceId` continue to use the agent sandbox.
|
package/dist/bin.mjs
CHANGED
|
@@ -301,6 +301,833 @@ function registerConnectorsCommand(program) {
|
|
|
301
301
|
});
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
// src/commands/device.ts
|
|
305
|
+
import { hostname } from "os";
|
|
306
|
+
import { SchemaError as SchemaError2, SchemaErrorCode as SchemaErrorCode2 } from "@stndrds/schema";
|
|
307
|
+
|
|
308
|
+
// src/config.ts
|
|
309
|
+
import { randomUUID } from "crypto";
|
|
310
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
311
|
+
import { homedir } from "os";
|
|
312
|
+
import { dirname, join } from "path";
|
|
313
|
+
var DEFAULT_API_URL = "http://localhost:4100/v1";
|
|
314
|
+
var ENV_PROFILE_NAME = "sandbox";
|
|
315
|
+
function getDefaultApiUrl() {
|
|
316
|
+
return DEFAULT_API_URL;
|
|
317
|
+
}
|
|
318
|
+
function getConfigPath() {
|
|
319
|
+
const configDir = process.env.STANDARDS_CONFIG_DIR ?? join(homedir(), ".standards");
|
|
320
|
+
return join(configDir, "config.json");
|
|
321
|
+
}
|
|
322
|
+
async function readConfig() {
|
|
323
|
+
try {
|
|
324
|
+
const raw = await readFile(getConfigPath(), "utf8");
|
|
325
|
+
const parsed = JSON.parse(raw);
|
|
326
|
+
return {
|
|
327
|
+
...parsed.installationId ? { installationId: parsed.installationId } : {},
|
|
328
|
+
currentProfile: parsed.currentProfile,
|
|
329
|
+
profiles: parsed.profiles ?? {}
|
|
330
|
+
};
|
|
331
|
+
} catch (error) {
|
|
332
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
333
|
+
return { profiles: {} };
|
|
334
|
+
}
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async function writeConfig(config) {
|
|
339
|
+
const path = getConfigPath();
|
|
340
|
+
const directory = dirname(path);
|
|
341
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
342
|
+
await chmod(directory, 448);
|
|
343
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}
|
|
344
|
+
`, { mode: 384 });
|
|
345
|
+
await chmod(path, 384);
|
|
346
|
+
}
|
|
347
|
+
function normalizeApiOrigin(apiUrl) {
|
|
348
|
+
try {
|
|
349
|
+
return new URL(apiUrl).origin;
|
|
350
|
+
} catch {
|
|
351
|
+
return apiUrl.replace(/\/+$/, "");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function upsertProfile(input2) {
|
|
355
|
+
const config = await readConfig();
|
|
356
|
+
const existingProfile = config.profiles[input2.name];
|
|
357
|
+
const device = existingProfile?.device && normalizeApiOrigin(existingProfile.apiUrl) === normalizeApiOrigin(input2.apiUrl) ? existingProfile.device : void 0;
|
|
358
|
+
config.profiles[input2.name] = {
|
|
359
|
+
apiUrl: input2.apiUrl,
|
|
360
|
+
apiKey: input2.apiKey,
|
|
361
|
+
...device ? { device } : {}
|
|
362
|
+
};
|
|
363
|
+
config.currentProfile = input2.name;
|
|
364
|
+
await writeConfig(config);
|
|
365
|
+
}
|
|
366
|
+
async function getOrCreateInstallationId() {
|
|
367
|
+
const config = await readConfig();
|
|
368
|
+
if (config.installationId) return config.installationId;
|
|
369
|
+
const installationId = randomUUID();
|
|
370
|
+
config.installationId = installationId;
|
|
371
|
+
await writeConfig(config);
|
|
372
|
+
return installationId;
|
|
373
|
+
}
|
|
374
|
+
async function getStoredDeviceCredential(profileName) {
|
|
375
|
+
const config = await readConfig();
|
|
376
|
+
return config.profiles[profileName]?.device ?? null;
|
|
377
|
+
}
|
|
378
|
+
async function storeDeviceCredential(profileName, credential) {
|
|
379
|
+
const config = await readConfig();
|
|
380
|
+
const profile = config.profiles[profileName];
|
|
381
|
+
if (!profile) return false;
|
|
382
|
+
profile.device = credential;
|
|
383
|
+
await writeConfig(config);
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
async function removeDeviceCredential(profileName) {
|
|
387
|
+
const config = await readConfig();
|
|
388
|
+
const profile = config.profiles[profileName];
|
|
389
|
+
if (!profile?.device) return false;
|
|
390
|
+
profile.device = void 0;
|
|
391
|
+
await writeConfig(config);
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
async function setCurrentProfile(name) {
|
|
395
|
+
const config = await readConfig();
|
|
396
|
+
if (!config.profiles[name]) {
|
|
397
|
+
throw new Error(`Unknown Standards instance "${name}"`);
|
|
398
|
+
}
|
|
399
|
+
config.currentProfile = name;
|
|
400
|
+
await writeConfig(config);
|
|
401
|
+
}
|
|
402
|
+
async function removeProfile(name) {
|
|
403
|
+
const config = await readConfig();
|
|
404
|
+
const profileName = name ?? config.currentProfile;
|
|
405
|
+
if (!profileName) return;
|
|
406
|
+
delete config.profiles[profileName];
|
|
407
|
+
if (config.currentProfile === profileName) {
|
|
408
|
+
config.currentProfile = void 0;
|
|
409
|
+
}
|
|
410
|
+
await writeConfig(config);
|
|
411
|
+
}
|
|
412
|
+
async function listProfiles() {
|
|
413
|
+
const config = await readConfig();
|
|
414
|
+
const profiles = Object.entries(config.profiles).sort(([a], [b]) => a.localeCompare(b)).map(([name, profile]) => ({
|
|
415
|
+
name,
|
|
416
|
+
apiUrl: profile.apiUrl,
|
|
417
|
+
current: config.currentProfile === name
|
|
418
|
+
}));
|
|
419
|
+
const envProfile = getEnvProfile();
|
|
420
|
+
if (envProfile) {
|
|
421
|
+
profiles.push({
|
|
422
|
+
name: ENV_PROFILE_NAME,
|
|
423
|
+
apiUrl: envProfile.apiUrl,
|
|
424
|
+
current: !config.currentProfile,
|
|
425
|
+
source: "env"
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
return profiles;
|
|
429
|
+
}
|
|
430
|
+
async function getActiveProfile() {
|
|
431
|
+
const config = await readConfig();
|
|
432
|
+
if (!config.currentProfile) {
|
|
433
|
+
const envProfile = getEnvProfile();
|
|
434
|
+
return envProfile ? { name: ENV_PROFILE_NAME, ...envProfile } : null;
|
|
435
|
+
}
|
|
436
|
+
const profile = config.profiles[config.currentProfile];
|
|
437
|
+
if (!profile) return null;
|
|
438
|
+
return { name: config.currentProfile, ...profile };
|
|
439
|
+
}
|
|
440
|
+
function getEnvProfile() {
|
|
441
|
+
if (!process.env.STANDARDS_API_KEY) return null;
|
|
442
|
+
return {
|
|
443
|
+
apiUrl: process.env.STANDARDS_API_URL ?? DEFAULT_API_URL,
|
|
444
|
+
apiKey: process.env.STANDARDS_API_KEY
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
async function resolveCliConfig(options) {
|
|
448
|
+
if (options.apiUrl || options.apiKey) {
|
|
449
|
+
return {
|
|
450
|
+
apiUrl: options.apiUrl ?? process.env.STANDARDS_API_URL ?? DEFAULT_API_URL,
|
|
451
|
+
apiKey: options.apiKey ?? process.env.STANDARDS_API_KEY
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
const config = await readConfig();
|
|
455
|
+
const profileName = options.instance ?? config.currentProfile;
|
|
456
|
+
if (profileName) {
|
|
457
|
+
const profile = config.profiles[profileName];
|
|
458
|
+
if (!profile) throw new Error(`Unknown Standards instance "${profileName}"`);
|
|
459
|
+
return {
|
|
460
|
+
apiUrl: profile.apiUrl,
|
|
461
|
+
apiKey: profile.apiKey,
|
|
462
|
+
profileName
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
apiUrl: process.env.STANDARDS_API_URL ?? DEFAULT_API_URL,
|
|
467
|
+
apiKey: process.env.STANDARDS_API_KEY
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// src/device/worker-client.ts
|
|
472
|
+
import { SchemaError, SchemaErrorCode } from "@stndrds/schema";
|
|
473
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
474
|
+
var DeviceWorkerRequestError = class extends SchemaError {
|
|
475
|
+
constructor(statusCode, message, code = SchemaErrorCode.REPOSITORY_QUERY_FAILED) {
|
|
476
|
+
super(message, code, { statusCode });
|
|
477
|
+
this.statusCode = statusCode;
|
|
478
|
+
this.name = "DeviceWorkerRequestError";
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
var DeviceCredentialRejectedError = class extends DeviceWorkerRequestError {
|
|
482
|
+
constructor() {
|
|
483
|
+
super(
|
|
484
|
+
401,
|
|
485
|
+
"Device credential rejected; run standards device pair again",
|
|
486
|
+
SchemaErrorCode.ACCESS_DENIED
|
|
487
|
+
);
|
|
488
|
+
this.name = "DeviceCredentialRejectedError";
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
function isRecord(value) {
|
|
492
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
493
|
+
}
|
|
494
|
+
async function readJson(response) {
|
|
495
|
+
const text = await response.text();
|
|
496
|
+
if (!text) return void 0;
|
|
497
|
+
try {
|
|
498
|
+
return JSON.parse(text);
|
|
499
|
+
} catch {
|
|
500
|
+
return void 0;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
function messageFrom(value, fallback) {
|
|
504
|
+
return isRecord(value) && typeof value.message === "string" ? value.message : fallback;
|
|
505
|
+
}
|
|
506
|
+
function parseLease(value) {
|
|
507
|
+
if (!isRecord(value)) throw new DeviceWorkerRequestError(200, "Invalid lease response");
|
|
508
|
+
const leaseToken = value.leaseToken;
|
|
509
|
+
if (!isRecord(value.command)) {
|
|
510
|
+
throw new DeviceWorkerRequestError(200, "Invalid lease response");
|
|
511
|
+
}
|
|
512
|
+
const leasedCommand = value.command;
|
|
513
|
+
const { id, intent, command, cwd, timeoutMs } = leasedCommand;
|
|
514
|
+
if (typeof id !== "string" || typeof leaseToken !== "string" || typeof intent !== "string" || typeof command !== "string" || cwd !== void 0 && cwd !== null && typeof cwd !== "string" || typeof timeoutMs !== "number" || !Number.isInteger(timeoutMs)) {
|
|
515
|
+
throw new DeviceWorkerRequestError(200, "Invalid lease response");
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
id,
|
|
519
|
+
leaseToken,
|
|
520
|
+
intent,
|
|
521
|
+
command,
|
|
522
|
+
timeoutMs,
|
|
523
|
+
...typeof cwd === "string" ? { cwd } : {}
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
function parseHeartbeat(value) {
|
|
527
|
+
if (!isRecord(value)) {
|
|
528
|
+
throw new DeviceWorkerRequestError(200, "Invalid heartbeat response");
|
|
529
|
+
}
|
|
530
|
+
const { cancel } = value;
|
|
531
|
+
if (typeof cancel !== "boolean") {
|
|
532
|
+
throw new DeviceWorkerRequestError(200, "Invalid heartbeat response");
|
|
533
|
+
}
|
|
534
|
+
const reason = value.reason;
|
|
535
|
+
if (reason !== void 0 && reason !== "cancelled" && reason !== "revoked") {
|
|
536
|
+
throw new DeviceWorkerRequestError(200, "Invalid heartbeat response");
|
|
537
|
+
}
|
|
538
|
+
return { cancel, ...reason === void 0 ? {} : { reason } };
|
|
539
|
+
}
|
|
540
|
+
function isAbortError(error) {
|
|
541
|
+
return error instanceof DOMException && error.name === "AbortError";
|
|
542
|
+
}
|
|
543
|
+
var DeviceWorkerClient = class {
|
|
544
|
+
constructor(options) {
|
|
545
|
+
this.options = options;
|
|
546
|
+
this.fetch = options.fetch ?? fetch;
|
|
547
|
+
this.apiUrl = options.apiUrl.replace(/\/+$/, "");
|
|
548
|
+
}
|
|
549
|
+
async request(path, input2 = {}) {
|
|
550
|
+
const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
551
|
+
const signal = input2.signal ? AbortSignal.any([input2.signal, timeoutSignal]) : timeoutSignal;
|
|
552
|
+
let response;
|
|
553
|
+
try {
|
|
554
|
+
response = await this.fetch(`${this.apiUrl}/device-worker${path}`, {
|
|
555
|
+
method: "POST",
|
|
556
|
+
signal,
|
|
557
|
+
headers: {
|
|
558
|
+
authorization: `Device ${this.options.token}`,
|
|
559
|
+
"content-type": "application/json",
|
|
560
|
+
...input2.leaseToken ? { "x-device-lease-token": input2.leaseToken } : {}
|
|
561
|
+
},
|
|
562
|
+
...input2.body === void 0 ? {} : { body: JSON.stringify(input2.body) }
|
|
563
|
+
});
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (input2.signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
566
|
+
if (isAbortError(error)) throw error;
|
|
567
|
+
throw new DeviceWorkerRequestError(0, "Device worker request failed");
|
|
568
|
+
}
|
|
569
|
+
if (response.status === 401) throw new DeviceCredentialRejectedError();
|
|
570
|
+
if (!response.ok) {
|
|
571
|
+
const body = await readJson(response);
|
|
572
|
+
throw new DeviceWorkerRequestError(
|
|
573
|
+
response.status,
|
|
574
|
+
messageFrom(body, `Device worker request failed with status ${response.status}`),
|
|
575
|
+
response.status === 409 ? SchemaErrorCode.CONFLICT : SchemaErrorCode.REPOSITORY_QUERY_FAILED
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
return response;
|
|
579
|
+
}
|
|
580
|
+
async lease(signal) {
|
|
581
|
+
const response = await this.request("/commands/lease", {
|
|
582
|
+
body: { version: "1", capabilities: ["exec"] },
|
|
583
|
+
signal
|
|
584
|
+
});
|
|
585
|
+
if (response.status === 204) return null;
|
|
586
|
+
return parseLease(await readJson(response));
|
|
587
|
+
}
|
|
588
|
+
async markRunning(commandId, leaseToken) {
|
|
589
|
+
await this.request(`/commands/${commandId}/running`, { leaseToken });
|
|
590
|
+
}
|
|
591
|
+
async heartbeat(commandId, leaseToken, signal) {
|
|
592
|
+
const response = await this.request(`/commands/${commandId}/heartbeat`, {
|
|
593
|
+
leaseToken,
|
|
594
|
+
signal
|
|
595
|
+
});
|
|
596
|
+
return parseHeartbeat(await readJson(response));
|
|
597
|
+
}
|
|
598
|
+
async output(commandId, leaseToken, stdout, stderr) {
|
|
599
|
+
await this.request(`/commands/${commandId}/output`, {
|
|
600
|
+
leaseToken,
|
|
601
|
+
body: { stdout, stderr }
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
async complete(commandId, leaseToken, result) {
|
|
605
|
+
await this.request(`/commands/${commandId}/complete`, {
|
|
606
|
+
leaseToken,
|
|
607
|
+
body: {
|
|
608
|
+
exitCode: result.exitCode,
|
|
609
|
+
success: result.success,
|
|
610
|
+
...result.errorCode ? { errorCode: result.errorCode } : {}
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
// src/device/process-runner.ts
|
|
617
|
+
import { spawn } from "child_process";
|
|
618
|
+
import { constants } from "fs";
|
|
619
|
+
import { access, stat } from "fs/promises";
|
|
620
|
+
import { isAbsolute } from "path";
|
|
621
|
+
import { StringDecoder } from "string_decoder";
|
|
622
|
+
var OUTPUT_LIMIT = 3e4;
|
|
623
|
+
var TERMINATION_GRACE_MS = 5e3;
|
|
624
|
+
var TERMINATION_POLL_MS = 50;
|
|
625
|
+
var BoundedTextBuffer = class {
|
|
626
|
+
constructor() {
|
|
627
|
+
this.decoder = new StringDecoder("utf8");
|
|
628
|
+
this.decoderFlushed = false;
|
|
629
|
+
this.value = "";
|
|
630
|
+
this.truncated = false;
|
|
631
|
+
}
|
|
632
|
+
append(chunk) {
|
|
633
|
+
if (this.truncated) return;
|
|
634
|
+
const text = typeof chunk === "string" ? chunk : this.decoder.write(Buffer.from(chunk));
|
|
635
|
+
this.appendText(text);
|
|
636
|
+
}
|
|
637
|
+
appendText(text) {
|
|
638
|
+
const remaining = OUTPUT_LIMIT - this.value.length;
|
|
639
|
+
if (text.length <= remaining) {
|
|
640
|
+
this.value += text;
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
this.value += text.slice(0, Math.max(remaining, 0));
|
|
644
|
+
this.truncated = true;
|
|
645
|
+
}
|
|
646
|
+
toString() {
|
|
647
|
+
if (!this.decoderFlushed) {
|
|
648
|
+
this.decoderFlushed = true;
|
|
649
|
+
this.appendText(this.decoder.end());
|
|
650
|
+
}
|
|
651
|
+
return this.truncated ? `${this.value}
|
|
652
|
+
... output truncated to ${OUTPUT_LIMIT} chars` : this.value;
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
function isMissingProcess(error) {
|
|
656
|
+
return error instanceof Error && "code" in error && error.code === "ESRCH";
|
|
657
|
+
}
|
|
658
|
+
function signalProcessGroup(pid, signal, kill) {
|
|
659
|
+
try {
|
|
660
|
+
kill(-pid, signal);
|
|
661
|
+
return true;
|
|
662
|
+
} catch (error) {
|
|
663
|
+
if (isMissingProcess(error)) return false;
|
|
664
|
+
throw error;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
function beginProcessGroupTermination(pid, deps = {}) {
|
|
668
|
+
const kill = deps.kill ?? process.kill.bind(process);
|
|
669
|
+
const setTimer = deps.setTimer ?? setTimeout;
|
|
670
|
+
const clearTimer = deps.clearTimer ?? clearTimeout;
|
|
671
|
+
return new Promise((resolve, reject) => {
|
|
672
|
+
let settled = false;
|
|
673
|
+
let escalationTimer;
|
|
674
|
+
let pollTimer;
|
|
675
|
+
function clearTimers() {
|
|
676
|
+
if (escalationTimer) clearTimer(escalationTimer);
|
|
677
|
+
if (pollTimer) clearTimer(pollTimer);
|
|
678
|
+
}
|
|
679
|
+
function finish() {
|
|
680
|
+
if (settled) return;
|
|
681
|
+
settled = true;
|
|
682
|
+
clearTimers();
|
|
683
|
+
resolve();
|
|
684
|
+
}
|
|
685
|
+
function fail(error) {
|
|
686
|
+
if (settled) return;
|
|
687
|
+
settled = true;
|
|
688
|
+
clearTimers();
|
|
689
|
+
reject(error);
|
|
690
|
+
}
|
|
691
|
+
function poll() {
|
|
692
|
+
if (settled) return;
|
|
693
|
+
try {
|
|
694
|
+
if (!signalProcessGroup(pid, 0, kill)) {
|
|
695
|
+
finish();
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
pollTimer = setTimer(poll, TERMINATION_POLL_MS);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
fail(error);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
try {
|
|
704
|
+
if (!signalProcessGroup(pid, "SIGTERM", kill)) {
|
|
705
|
+
finish();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
escalationTimer = setTimer(() => {
|
|
709
|
+
try {
|
|
710
|
+
if (!signalProcessGroup(pid, 0, kill)) {
|
|
711
|
+
finish();
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
signalProcessGroup(pid, "SIGKILL", kill);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
fail(error);
|
|
717
|
+
}
|
|
718
|
+
}, TERMINATION_GRACE_MS);
|
|
719
|
+
pollTimer = setTimer(poll, TERMINATION_POLL_MS);
|
|
720
|
+
} catch (error) {
|
|
721
|
+
fail(error);
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
async function resolveShell(shell = process.env.SHELL) {
|
|
726
|
+
if (!(shell && isAbsolute(shell))) return "/bin/sh";
|
|
727
|
+
try {
|
|
728
|
+
const metadata = await stat(shell);
|
|
729
|
+
if (!metadata.isFile()) return "/bin/sh";
|
|
730
|
+
await access(shell, constants.X_OK);
|
|
731
|
+
return shell;
|
|
732
|
+
} catch {
|
|
733
|
+
return "/bin/sh";
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
function terminalResult(reason, stdout, stderr) {
|
|
737
|
+
return {
|
|
738
|
+
stdout: stdout.toString(),
|
|
739
|
+
stderr: stderr.toString(),
|
|
740
|
+
exitCode: reason === "timed_out" ? 124 : 130,
|
|
741
|
+
success: false,
|
|
742
|
+
errorCode: reason
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
async function runDeviceProcess(input2) {
|
|
746
|
+
if (input2.signal?.aborted) {
|
|
747
|
+
return { stdout: "", stderr: "", exitCode: 130, success: false, errorCode: "cancelled" };
|
|
748
|
+
}
|
|
749
|
+
const shell = await resolveShell();
|
|
750
|
+
if (input2.signal?.aborted) {
|
|
751
|
+
return { stdout: "", stderr: "", exitCode: 130, success: false, errorCode: "cancelled" };
|
|
752
|
+
}
|
|
753
|
+
const stdout = new BoundedTextBuffer();
|
|
754
|
+
const stderr = new BoundedTextBuffer();
|
|
755
|
+
return new Promise((resolve) => {
|
|
756
|
+
let settled = false;
|
|
757
|
+
let terminationReason;
|
|
758
|
+
let termination;
|
|
759
|
+
let child;
|
|
760
|
+
function finish(result) {
|
|
761
|
+
if (settled) return;
|
|
762
|
+
settled = true;
|
|
763
|
+
clearTimeout(timeout);
|
|
764
|
+
input2.signal?.removeEventListener("abort", cancel);
|
|
765
|
+
resolve(result);
|
|
766
|
+
}
|
|
767
|
+
function finishTerminationFailure(error) {
|
|
768
|
+
finish({
|
|
769
|
+
stdout: stdout.toString(),
|
|
770
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
771
|
+
exitCode: 1,
|
|
772
|
+
success: false,
|
|
773
|
+
errorCode: "execution_failed"
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
function terminate(reason) {
|
|
777
|
+
if (settled || terminationReason || !child?.pid) return;
|
|
778
|
+
terminationReason = reason;
|
|
779
|
+
termination = beginProcessGroupTermination(child.pid);
|
|
780
|
+
termination.catch(finishTerminationFailure);
|
|
781
|
+
}
|
|
782
|
+
function cancel() {
|
|
783
|
+
terminate("cancelled");
|
|
784
|
+
}
|
|
785
|
+
const timeout = setTimeout(() => terminate("timed_out"), input2.timeoutMs);
|
|
786
|
+
try {
|
|
787
|
+
const spawned2 = spawn(shell, ["-lc", input2.command], {
|
|
788
|
+
cwd: input2.cwd,
|
|
789
|
+
env: process.env,
|
|
790
|
+
detached: true,
|
|
791
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
792
|
+
});
|
|
793
|
+
child = spawned2;
|
|
794
|
+
} catch (error) {
|
|
795
|
+
finish({
|
|
796
|
+
stdout: "",
|
|
797
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
798
|
+
exitCode: 1,
|
|
799
|
+
success: false,
|
|
800
|
+
errorCode: "execution_failed"
|
|
801
|
+
});
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
input2.signal?.addEventListener("abort", cancel, { once: true });
|
|
805
|
+
if (input2.signal?.aborted) cancel();
|
|
806
|
+
const spawned = child;
|
|
807
|
+
spawned.stdout.on("data", (chunk) => stdout.append(chunk));
|
|
808
|
+
spawned.stderr.on("data", (chunk) => stderr.append(chunk));
|
|
809
|
+
spawned.once("error", (error) => {
|
|
810
|
+
finish({
|
|
811
|
+
stdout: stdout.toString(),
|
|
812
|
+
stderr: error.message,
|
|
813
|
+
exitCode: 1,
|
|
814
|
+
success: false,
|
|
815
|
+
errorCode: "execution_failed"
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
spawned.once("close", (code) => {
|
|
819
|
+
if (terminationReason) {
|
|
820
|
+
const reason = terminationReason;
|
|
821
|
+
(termination ?? Promise.resolve()).then(
|
|
822
|
+
() => finish(terminalResult(reason, stdout, stderr)),
|
|
823
|
+
finishTerminationFailure
|
|
824
|
+
);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const exitCode = code ?? 1;
|
|
828
|
+
finish({
|
|
829
|
+
stdout: stdout.toString(),
|
|
830
|
+
stderr: stderr.toString(),
|
|
831
|
+
exitCode,
|
|
832
|
+
success: exitCode === 0,
|
|
833
|
+
...code === null ? { errorCode: "execution_failed" } : {}
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// src/device/worker-loop.ts
|
|
840
|
+
var HEARTBEAT_INTERVAL_MS = 2e3;
|
|
841
|
+
var INITIAL_RETRY_MS = 250;
|
|
842
|
+
var MAX_RETRY_MS = 1e4;
|
|
843
|
+
var RETRY_JITTER_MS = 250;
|
|
844
|
+
function computeRetryDelay(attempt, random = Math.random) {
|
|
845
|
+
const exponential = Math.min(INITIAL_RETRY_MS * 2 ** attempt, MAX_RETRY_MS);
|
|
846
|
+
return exponential + Math.floor(random() * RETRY_JITTER_MS);
|
|
847
|
+
}
|
|
848
|
+
function sleepWithAbort(milliseconds, signal) {
|
|
849
|
+
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
850
|
+
return new Promise((resolve, reject) => {
|
|
851
|
+
function abort() {
|
|
852
|
+
clearTimeout(timer);
|
|
853
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
854
|
+
}
|
|
855
|
+
const timer = setTimeout(() => {
|
|
856
|
+
signal?.removeEventListener("abort", abort);
|
|
857
|
+
resolve();
|
|
858
|
+
}, milliseconds);
|
|
859
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
function isAbortError2(error) {
|
|
863
|
+
return error instanceof DOMException && error.name === "AbortError";
|
|
864
|
+
}
|
|
865
|
+
function isRetryableIdleError(error) {
|
|
866
|
+
return error instanceof DeviceWorkerRequestError && (error.statusCode === 0 || error.statusCode >= 500);
|
|
867
|
+
}
|
|
868
|
+
function isLeaseConflict(error) {
|
|
869
|
+
return error instanceof DeviceWorkerRequestError && error.statusCode === 409;
|
|
870
|
+
}
|
|
871
|
+
async function executeLease(command, input2, runProcess, sleep) {
|
|
872
|
+
try {
|
|
873
|
+
await input2.client.markRunning(command.id, command.leaseToken);
|
|
874
|
+
} catch (error) {
|
|
875
|
+
if (error instanceof DeviceCredentialRejectedError) throw error;
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
input2.onEvent?.({ type: "running", commandId: command.id, intent: command.intent });
|
|
879
|
+
const commandController = new AbortController();
|
|
880
|
+
const heartbeatController = new AbortController();
|
|
881
|
+
let completionAllowed = true;
|
|
882
|
+
let heartbeatFailure;
|
|
883
|
+
function abortCommand() {
|
|
884
|
+
commandController.abort();
|
|
885
|
+
}
|
|
886
|
+
input2.signal.addEventListener("abort", abortCommand, { once: true });
|
|
887
|
+
const processPromise = runProcess({
|
|
888
|
+
command: command.command,
|
|
889
|
+
cwd: command.cwd,
|
|
890
|
+
timeoutMs: command.timeoutMs,
|
|
891
|
+
signal: commandController.signal
|
|
892
|
+
});
|
|
893
|
+
const heartbeatPromise = (async () => {
|
|
894
|
+
while (!heartbeatController.signal.aborted) {
|
|
895
|
+
try {
|
|
896
|
+
await sleep(HEARTBEAT_INTERVAL_MS, heartbeatController.signal);
|
|
897
|
+
const heartbeat = await input2.client.heartbeat(
|
|
898
|
+
command.id,
|
|
899
|
+
command.leaseToken,
|
|
900
|
+
heartbeatController.signal
|
|
901
|
+
);
|
|
902
|
+
if (heartbeat.cancel) {
|
|
903
|
+
if (heartbeat.reason === "revoked") completionAllowed = false;
|
|
904
|
+
commandController.abort();
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
} catch (error) {
|
|
908
|
+
if (heartbeatController.signal.aborted && isAbortError2(error)) return;
|
|
909
|
+
heartbeatFailure = error;
|
|
910
|
+
completionAllowed = false;
|
|
911
|
+
commandController.abort();
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
})();
|
|
916
|
+
let result;
|
|
917
|
+
try {
|
|
918
|
+
result = await processPromise;
|
|
919
|
+
} catch (error) {
|
|
920
|
+
result = {
|
|
921
|
+
stdout: "",
|
|
922
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
923
|
+
exitCode: 1,
|
|
924
|
+
success: false,
|
|
925
|
+
errorCode: "execution_failed"
|
|
926
|
+
};
|
|
927
|
+
} finally {
|
|
928
|
+
heartbeatController.abort();
|
|
929
|
+
await heartbeatPromise;
|
|
930
|
+
input2.signal.removeEventListener("abort", abortCommand);
|
|
931
|
+
}
|
|
932
|
+
if (heartbeatFailure instanceof DeviceCredentialRejectedError) throw heartbeatFailure;
|
|
933
|
+
if (!completionAllowed || isLeaseConflict(heartbeatFailure)) return;
|
|
934
|
+
try {
|
|
935
|
+
await input2.client.output(command.id, command.leaseToken, result.stdout, result.stderr);
|
|
936
|
+
await input2.client.complete(command.id, command.leaseToken, result);
|
|
937
|
+
} catch (error) {
|
|
938
|
+
if (error instanceof DeviceCredentialRejectedError) throw error;
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
input2.onEvent?.({ type: "completed", commandId: command.id, exitCode: result.exitCode });
|
|
942
|
+
}
|
|
943
|
+
async function runDeviceWorker(input2) {
|
|
944
|
+
const runProcess = input2.runProcess ?? runDeviceProcess;
|
|
945
|
+
const sleep = input2.sleep ?? sleepWithAbort;
|
|
946
|
+
const random = input2.random ?? Math.random;
|
|
947
|
+
let retryAttempt = 0;
|
|
948
|
+
while (!input2.signal.aborted) {
|
|
949
|
+
let command;
|
|
950
|
+
try {
|
|
951
|
+
command = await input2.client.lease(input2.signal);
|
|
952
|
+
retryAttempt = 0;
|
|
953
|
+
} catch (error) {
|
|
954
|
+
if (input2.signal.aborted) return;
|
|
955
|
+
if (error instanceof DeviceCredentialRejectedError) throw error;
|
|
956
|
+
if (!isRetryableIdleError(error)) throw error;
|
|
957
|
+
try {
|
|
958
|
+
await sleep(computeRetryDelay(retryAttempt, random), input2.signal);
|
|
959
|
+
} catch (sleepError) {
|
|
960
|
+
if (input2.signal.aborted && isAbortError2(sleepError)) return;
|
|
961
|
+
throw sleepError;
|
|
962
|
+
}
|
|
963
|
+
retryAttempt += 1;
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (!command) continue;
|
|
967
|
+
await executeLease(command, input2, runProcess, sleep);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// src/commands/device.ts
|
|
972
|
+
var NAMED_PROFILE_REQUIRED = "Device pairing requires a named Standards profile";
|
|
973
|
+
var SUPPORTED_PLATFORMS_REQUIRED = "Device workers support macOS and Linux only";
|
|
974
|
+
var DeviceCommandError = class extends SchemaError2 {
|
|
975
|
+
constructor(message) {
|
|
976
|
+
super(message, SchemaErrorCode2.VALIDATION_FAILED);
|
|
977
|
+
this.name = "DeviceCommandError";
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
function requireSupportedPlatform(platform) {
|
|
981
|
+
if (platform === "darwin" || platform === "linux") return platform;
|
|
982
|
+
throw new DeviceCommandError(SUPPORTED_PLATFORMS_REQUIRED);
|
|
983
|
+
}
|
|
984
|
+
function requireNamedProfile(command) {
|
|
985
|
+
const options = getGlobalOptions(command);
|
|
986
|
+
if (!(options.profileName && options.apiUrl && options.apiKey)) {
|
|
987
|
+
throw new DeviceCommandError(NAMED_PROFILE_REQUIRED);
|
|
988
|
+
}
|
|
989
|
+
return {
|
|
990
|
+
apiUrl: options.apiUrl,
|
|
991
|
+
apiKey: options.apiKey,
|
|
992
|
+
profileName: options.profileName,
|
|
993
|
+
tenantId: options.tenant
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
function isRecord2(value) {
|
|
997
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
998
|
+
}
|
|
999
|
+
function parseDeviceView(value) {
|
|
1000
|
+
if (!isRecord2(value)) throw new DeviceCommandError("Invalid device response");
|
|
1001
|
+
const { id, name, platform, arch, hostname: deviceHostname } = value;
|
|
1002
|
+
const { status } = value;
|
|
1003
|
+
if (typeof id !== "string" || typeof name !== "string" || platform !== "darwin" && platform !== "linux" || typeof arch !== "string" || typeof deviceHostname !== "string" || status !== "online" && status !== "offline" && status !== "revoked") {
|
|
1004
|
+
throw new DeviceCommandError("Invalid device response");
|
|
1005
|
+
}
|
|
1006
|
+
return { id, name, platform, arch, hostname: deviceHostname, status };
|
|
1007
|
+
}
|
|
1008
|
+
function parsePairDeviceResponse(value) {
|
|
1009
|
+
if (!isRecord2(value) || typeof value.token !== "string") {
|
|
1010
|
+
throw new DeviceCommandError("Invalid device pairing response");
|
|
1011
|
+
}
|
|
1012
|
+
return { device: parseDeviceView(value.device), token: value.token };
|
|
1013
|
+
}
|
|
1014
|
+
function parseDeviceList(value) {
|
|
1015
|
+
if (!Array.isArray(value)) throw new DeviceCommandError("Invalid device list response");
|
|
1016
|
+
return value.map(parseDeviceView);
|
|
1017
|
+
}
|
|
1018
|
+
async function pairDevice(name, command) {
|
|
1019
|
+
const platform = requireSupportedPlatform(process.platform);
|
|
1020
|
+
const profile = requireNamedProfile(command);
|
|
1021
|
+
const installationId = await getOrCreateInstallationId();
|
|
1022
|
+
const client = createClient({
|
|
1023
|
+
apiUrl: profile.apiUrl,
|
|
1024
|
+
apiKey: profile.apiKey,
|
|
1025
|
+
tenantId: profile.tenantId
|
|
1026
|
+
});
|
|
1027
|
+
const response = parsePairDeviceResponse(
|
|
1028
|
+
await client.post("/devices/pair", {
|
|
1029
|
+
installationId,
|
|
1030
|
+
name,
|
|
1031
|
+
platform,
|
|
1032
|
+
arch: process.arch,
|
|
1033
|
+
hostname: hostname()
|
|
1034
|
+
})
|
|
1035
|
+
);
|
|
1036
|
+
const stored = await storeDeviceCredential(profile.profileName, {
|
|
1037
|
+
id: response.device.id,
|
|
1038
|
+
name: response.device.name,
|
|
1039
|
+
token: response.token
|
|
1040
|
+
});
|
|
1041
|
+
if (!stored) throw new DeviceCommandError(NAMED_PROFILE_REQUIRED);
|
|
1042
|
+
process.stdout.write(`Paired ${response.device.name} (${response.device.id})
|
|
1043
|
+
`);
|
|
1044
|
+
}
|
|
1045
|
+
async function showDeviceStatus(command) {
|
|
1046
|
+
const profile = requireNamedProfile(command);
|
|
1047
|
+
const credential = await getStoredDeviceCredential(profile.profileName);
|
|
1048
|
+
if (!credential) {
|
|
1049
|
+
throw new DeviceCommandError("No paired device; run standards device pair first");
|
|
1050
|
+
}
|
|
1051
|
+
const client = createClient({
|
|
1052
|
+
apiUrl: profile.apiUrl,
|
|
1053
|
+
apiKey: profile.apiKey,
|
|
1054
|
+
tenantId: profile.tenantId
|
|
1055
|
+
});
|
|
1056
|
+
const devices = parseDeviceList(await client.get("/devices"));
|
|
1057
|
+
const pairedDevice = devices.find((device) => device.id === credential.id);
|
|
1058
|
+
formatOutput(
|
|
1059
|
+
pairedDevice ? [pairedDevice] : [{ id: credential.id, name: credential.name, status: "revoked" }],
|
|
1060
|
+
getFormat(command)
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
async function revokeDevice(command) {
|
|
1064
|
+
const profile = requireNamedProfile(command);
|
|
1065
|
+
const credential = await getStoredDeviceCredential(profile.profileName);
|
|
1066
|
+
if (!credential) {
|
|
1067
|
+
throw new DeviceCommandError("No paired device; run standards device pair first");
|
|
1068
|
+
}
|
|
1069
|
+
const client = createClient({
|
|
1070
|
+
apiUrl: profile.apiUrl,
|
|
1071
|
+
apiKey: profile.apiKey,
|
|
1072
|
+
tenantId: profile.tenantId
|
|
1073
|
+
});
|
|
1074
|
+
await client.delete(`/devices/${credential.id}`);
|
|
1075
|
+
await removeDeviceCredential(profile.profileName);
|
|
1076
|
+
}
|
|
1077
|
+
async function serveDevice(command) {
|
|
1078
|
+
const platform = requireSupportedPlatform(process.platform);
|
|
1079
|
+
const profile = requireNamedProfile(command);
|
|
1080
|
+
const credential = await getStoredDeviceCredential(profile.profileName);
|
|
1081
|
+
if (!credential) {
|
|
1082
|
+
throw new DeviceCommandError("No paired device; run standards device pair first");
|
|
1083
|
+
}
|
|
1084
|
+
const userClient = createClient({
|
|
1085
|
+
apiUrl: profile.apiUrl,
|
|
1086
|
+
apiKey: profile.apiKey,
|
|
1087
|
+
tenantId: profile.tenantId
|
|
1088
|
+
});
|
|
1089
|
+
const devices = parseDeviceList(await userClient.get("/devices"));
|
|
1090
|
+
const device = devices.find((candidate) => candidate.id === credential.id);
|
|
1091
|
+
if (!device) throw new DeviceCommandError("Device is unavailable");
|
|
1092
|
+
if (device.platform !== platform) {
|
|
1093
|
+
throw new DeviceCommandError("Device platform does not match paired device");
|
|
1094
|
+
}
|
|
1095
|
+
const controller = new AbortController();
|
|
1096
|
+
const stop = () => controller.abort();
|
|
1097
|
+
process.once("SIGINT", stop);
|
|
1098
|
+
process.once("SIGTERM", stop);
|
|
1099
|
+
process.stdout.write(`Connected as ${credential.name}
|
|
1100
|
+
`);
|
|
1101
|
+
process.stdout.write("Waiting for commands \u2014 press Ctrl+C to disconnect\n");
|
|
1102
|
+
try {
|
|
1103
|
+
await runDeviceWorker({
|
|
1104
|
+
client: new DeviceWorkerClient({ apiUrl: profile.apiUrl, token: credential.token }),
|
|
1105
|
+
signal: controller.signal,
|
|
1106
|
+
onEvent: (event) => {
|
|
1107
|
+
if (event.type === "running") {
|
|
1108
|
+
process.stdout.write(`Running ${event.commandId}: ${event.intent}
|
|
1109
|
+
`);
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
process.stdout.write(`Completed ${event.commandId} with exit code ${event.exitCode}
|
|
1113
|
+
`);
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
} finally {
|
|
1117
|
+
process.removeListener("SIGINT", stop);
|
|
1118
|
+
process.removeListener("SIGTERM", stop);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
function registerDeviceCommand(program) {
|
|
1122
|
+
const device = program.command("device").description("Pair and run this computer as a device");
|
|
1123
|
+
device.command("pair").description("Pair this computer with Standards").requiredOption("--name <name>", "device name").action(
|
|
1124
|
+
async (options, command) => pairDevice(options.name, command)
|
|
1125
|
+
);
|
|
1126
|
+
device.command("status").description("Show the paired device status").action(async (_options, command) => showDeviceStatus(command));
|
|
1127
|
+
device.command("revoke").description("Revoke this computer's device credential").action(async (_options, command) => revokeDevice(command));
|
|
1128
|
+
device.command("serve").description("Run the foreground device worker").action(async (_options, command) => serveDevice(command));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
304
1131
|
// src/commands/documents.ts
|
|
305
1132
|
import { formatByteSize } from "@stndrds/schema";
|
|
306
1133
|
function toFileRow(file) {
|
|
@@ -380,7 +1207,7 @@ function registerDocumentsCommand(program) {
|
|
|
380
1207
|
}
|
|
381
1208
|
|
|
382
1209
|
// src/commands/folders.ts
|
|
383
|
-
import { readFile } from "fs/promises";
|
|
1210
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
384
1211
|
function registerFoldersCommand(program) {
|
|
385
1212
|
const folders = program.command("folders").description("Manage record drive folders");
|
|
386
1213
|
folders.command("reconcile-paths").description("Create a folder tree on a record drive from paths").argument("<object>", "object name (e.g. contacts)").argument("<recordId>", "record ID owning the drive").option(
|
|
@@ -394,7 +1221,7 @@ function registerFoldersCommand(program) {
|
|
|
394
1221
|
).option("--paths-file <file>", "file containing one path per line").option("--mode <mode>", "reconcile mode: repair or dryRun", "repair").action(async (objectName, recordId, opts, cmd) => {
|
|
395
1222
|
const paths = [...opts.path];
|
|
396
1223
|
if (opts.pathsFile) {
|
|
397
|
-
const content = await
|
|
1224
|
+
const content = await readFile2(opts.pathsFile, "utf-8");
|
|
398
1225
|
const filePaths = content.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
399
1226
|
paths.push(...filePaths);
|
|
400
1227
|
}
|
|
@@ -528,7 +1355,12 @@ function registerMcpCommand(program) {
|
|
|
528
1355
|
}
|
|
529
1356
|
|
|
530
1357
|
// src/commands/meetings.ts
|
|
531
|
-
import {
|
|
1358
|
+
import { createHmac } from "crypto";
|
|
1359
|
+
import {
|
|
1360
|
+
FAKE_MEETING_BOT_SIGNATURE_HEADER,
|
|
1361
|
+
RECORDING_OVERRIDES,
|
|
1362
|
+
ValidationError as ValidationError3
|
|
1363
|
+
} from "@stndrds/schema";
|
|
532
1364
|
var ORDERS = ["asc", "desc"];
|
|
533
1365
|
function parseOrder(order) {
|
|
534
1366
|
if (ORDERS.includes(order)) return order;
|
|
@@ -553,6 +1385,43 @@ function buildQuery(opts) {
|
|
|
553
1385
|
if (opts.offset !== void 0) query.offset = parseCount("--offset", opts.offset);
|
|
554
1386
|
return query;
|
|
555
1387
|
}
|
|
1388
|
+
function parseOverride(opts) {
|
|
1389
|
+
const chosen = RECORDING_OVERRIDES.filter(
|
|
1390
|
+
(override) => opts[override]
|
|
1391
|
+
);
|
|
1392
|
+
if (opts.auto) chosen.push(null);
|
|
1393
|
+
if (chosen.length !== 1) {
|
|
1394
|
+
throw new ValidationError3("record needs exactly one of --force, --skip or --auto", []);
|
|
1395
|
+
}
|
|
1396
|
+
return chosen[0] ?? null;
|
|
1397
|
+
}
|
|
1398
|
+
var SIMULATED_LINE_MS = 4e3;
|
|
1399
|
+
var DEFAULT_SIMULATED_LINES = 12;
|
|
1400
|
+
var SIMULATED_SENTENCES = [
|
|
1401
|
+
"Bonjour \xE0 tous, merci d'\xEAtre l\xE0, on commence par le point d'avancement.",
|
|
1402
|
+
"De mon c\xF4t\xE9 la maquette est valid\xE9e, il reste les retours du client.",
|
|
1403
|
+
"On garde le planning initial, la livraison est pr\xE9vue pour vendredi.",
|
|
1404
|
+
"Est-ce que quelqu'un a des questions sur le budget de la phase deux ?",
|
|
1405
|
+
"Je propose qu'on fasse un point rapide jeudi pour v\xE9rifier les tests.",
|
|
1406
|
+
"Tr\xE8s bien, je vous envoie le compte rendu dans la journ\xE9e, bonne journ\xE9e."
|
|
1407
|
+
];
|
|
1408
|
+
function simulatedSpeakers(meeting) {
|
|
1409
|
+
const speakers = meeting.participants.map(
|
|
1410
|
+
(participant) => participant.name?.trim() || participant.address.split("@")[0] || participant.address
|
|
1411
|
+
);
|
|
1412
|
+
return speakers.length > 0 ? speakers : ["Speaker"];
|
|
1413
|
+
}
|
|
1414
|
+
function buildSimulatedSegments(speakers, lines) {
|
|
1415
|
+
return Array.from({ length: lines }, (_, index) => ({
|
|
1416
|
+
speaker: speakers[index % speakers.length] ?? null,
|
|
1417
|
+
startsAtMs: index * SIMULATED_LINE_MS,
|
|
1418
|
+
endsAtMs: (index + 1) * SIMULATED_LINE_MS,
|
|
1419
|
+
text: SIMULATED_SENTENCES[index % SIMULATED_SENTENCES.length] ?? ""
|
|
1420
|
+
}));
|
|
1421
|
+
}
|
|
1422
|
+
function signFakeWebhook(secret, body) {
|
|
1423
|
+
return createHmac("sha256", secret).update(body).digest("hex");
|
|
1424
|
+
}
|
|
556
1425
|
function withQueryOptions(command) {
|
|
557
1426
|
return command.option("--search <text>", "match title, description and location").option("--since <iso>", "inclusive ISO lower bound on the start").option("--before <iso>", "exclusive ISO upper bound on the start").option("--addresses <emails>", "comma-separated participant addresses to restrict to").option("--record <id>", "only meetings linked to this record").option("--order <order>", "asc for what is coming next, desc to read history").option("--limit <n>", "max meetings to return").option("--offset <n>", "number of meetings to skip");
|
|
558
1427
|
}
|
|
@@ -589,6 +1458,65 @@ function registerMeetingsCommand(program) {
|
|
|
589
1458
|
formatOutput(result, getFormat(cmd));
|
|
590
1459
|
}
|
|
591
1460
|
);
|
|
1461
|
+
meetings.command("transcript").description("Read a meeting's completed transcript").argument("<id>", "meeting ID").action(async (id, _opts, cmd) => {
|
|
1462
|
+
const client = getClientFromCommand(cmd);
|
|
1463
|
+
const result = await client.get(`/meetings/${id}/transcript`);
|
|
1464
|
+
formatOutput(result, getFormat(cmd));
|
|
1465
|
+
});
|
|
1466
|
+
meetings.command("record").description("Force, skip, or hand back to the workspace policy the recording of a meeting").argument("<id>", "meeting ID").option("--force", "record this meeting whatever the workspace policy says").option("--skip", "never record this meeting").option("--auto", "clear the override and follow the workspace policy").action(async (id, opts, cmd) => {
|
|
1467
|
+
const override = parseOverride(opts);
|
|
1468
|
+
const client = getClientFromCommand(cmd);
|
|
1469
|
+
const result = await client.put(`/meetings/${id}/recording`, { override });
|
|
1470
|
+
formatOutput(result, getFormat(cmd));
|
|
1471
|
+
});
|
|
1472
|
+
meetings.command("simulate-transcript").description("Complete a fake-provider bot by posting a generated transcript to its webhook").argument("<id>", "meeting ID").option(
|
|
1473
|
+
"--lines <n>",
|
|
1474
|
+
"number of transcript lines to generate",
|
|
1475
|
+
String(DEFAULT_SIMULATED_LINES)
|
|
1476
|
+
).action(async (id, opts, cmd) => {
|
|
1477
|
+
const secret = process.env.MEETING_BOT_FAKE_SECRET;
|
|
1478
|
+
if (!secret) {
|
|
1479
|
+
throw new ValidationError3("MEETING_BOT_FAKE_SECRET is not set in this shell", []);
|
|
1480
|
+
}
|
|
1481
|
+
const lines = parseCount("--lines", opts.lines);
|
|
1482
|
+
if (lines < 1) throw new ValidationError3("--lines must be at least 1", []);
|
|
1483
|
+
const client = getClientFromCommand(cmd);
|
|
1484
|
+
const meeting = await client.get(`/meetings/${id}`);
|
|
1485
|
+
const { status, botId } = meeting.recording;
|
|
1486
|
+
if (status !== "scheduled" && status !== "recording" || botId === null) {
|
|
1487
|
+
throw new ValidationError3(`Meeting ${id} has no bot to complete (status: ${status})`, []);
|
|
1488
|
+
}
|
|
1489
|
+
const segments = buildSimulatedSegments(simulatedSpeakers(meeting), lines);
|
|
1490
|
+
const startedAt = meeting.startsAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1491
|
+
const endedAt = new Date(
|
|
1492
|
+
new Date(startedAt).getTime() + lines * SIMULATED_LINE_MS
|
|
1493
|
+
).toISOString();
|
|
1494
|
+
const body = JSON.stringify({
|
|
1495
|
+
botId,
|
|
1496
|
+
transcript: {
|
|
1497
|
+
fullText: segments.map((segment) => segment.text).join("\n"),
|
|
1498
|
+
segments,
|
|
1499
|
+
language: "fr",
|
|
1500
|
+
startedAt,
|
|
1501
|
+
endedAt
|
|
1502
|
+
}
|
|
1503
|
+
});
|
|
1504
|
+
const response = await fetch(
|
|
1505
|
+
`${getGlobalOptions(cmd).apiUrl}/meetings/transcripts/webhook/fake`,
|
|
1506
|
+
{
|
|
1507
|
+
method: "POST",
|
|
1508
|
+
headers: {
|
|
1509
|
+
"content-type": "application/json",
|
|
1510
|
+
[FAKE_MEETING_BOT_SIGNATURE_HEADER]: signFakeWebhook(secret, body)
|
|
1511
|
+
},
|
|
1512
|
+
body
|
|
1513
|
+
}
|
|
1514
|
+
);
|
|
1515
|
+
if (!response.ok) {
|
|
1516
|
+
throw new ValidationError3(`webhook answered ${response.status}`, []);
|
|
1517
|
+
}
|
|
1518
|
+
formatOutput({ botId, segments: segments.length }, getFormat(cmd));
|
|
1519
|
+
});
|
|
592
1520
|
}
|
|
593
1521
|
|
|
594
1522
|
// src/commands/pull.ts
|
|
@@ -738,7 +1666,7 @@ function registerPullCommand(program) {
|
|
|
738
1666
|
}
|
|
739
1667
|
|
|
740
1668
|
// src/commands/records.ts
|
|
741
|
-
import { readFile as
|
|
1669
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
742
1670
|
import { basename } from "path";
|
|
743
1671
|
function parseSortFlag(sort) {
|
|
744
1672
|
const [attribute, direction = "asc"] = sort.split(":");
|
|
@@ -804,7 +1732,7 @@ function registerRecordsCommand(program) {
|
|
|
804
1732
|
formatOutput(result, getFormat(cmd));
|
|
805
1733
|
});
|
|
806
1734
|
records.command("attach-document").description("Upload and attach a local file to a record").argument("<object>", "object name (e.g. contacts)").argument("<recordId>", "record ID").requiredOption("--file <path>", "local file path to upload").option("--attribute <attr>", "attribute name to attach the document to").option("--parent-id <folderId>", "parent folder ID").option("--title <title>", "document title (defaults to filename)").action(async (objectName, recordId, opts, cmd) => {
|
|
807
|
-
const fileContent = await
|
|
1735
|
+
const fileContent = await readFile3(opts.file);
|
|
808
1736
|
const fileName = basename(opts.file);
|
|
809
1737
|
const title = opts.title ?? fileName;
|
|
810
1738
|
const form = new FormData();
|
|
@@ -825,128 +1753,6 @@ function registerRecordsCommand(program) {
|
|
|
825
1753
|
import { stdin as input, stdout as output } from "process";
|
|
826
1754
|
import { createInterface } from "readline/promises";
|
|
827
1755
|
import chalk7 from "chalk";
|
|
828
|
-
|
|
829
|
-
// src/config.ts
|
|
830
|
-
import { mkdir, readFile as readFile3, rm, writeFile } from "fs/promises";
|
|
831
|
-
import { homedir } from "os";
|
|
832
|
-
import { dirname, join } from "path";
|
|
833
|
-
var DEFAULT_API_URL = "http://localhost:4100/v1";
|
|
834
|
-
var ENV_PROFILE_NAME = "sandbox";
|
|
835
|
-
function getDefaultApiUrl() {
|
|
836
|
-
return DEFAULT_API_URL;
|
|
837
|
-
}
|
|
838
|
-
function getConfigPath() {
|
|
839
|
-
const configDir = process.env.STANDARDS_CONFIG_DIR ?? join(homedir(), ".standards");
|
|
840
|
-
return join(configDir, "config.json");
|
|
841
|
-
}
|
|
842
|
-
async function readConfig() {
|
|
843
|
-
try {
|
|
844
|
-
const raw = await readFile3(getConfigPath(), "utf8");
|
|
845
|
-
const parsed = JSON.parse(raw);
|
|
846
|
-
return {
|
|
847
|
-
currentProfile: parsed.currentProfile,
|
|
848
|
-
profiles: parsed.profiles ?? {}
|
|
849
|
-
};
|
|
850
|
-
} catch (error) {
|
|
851
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
852
|
-
return { profiles: {} };
|
|
853
|
-
}
|
|
854
|
-
throw error;
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
async function writeConfig(config) {
|
|
858
|
-
const path = getConfigPath();
|
|
859
|
-
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
860
|
-
await writeFile(path, `${JSON.stringify(config, null, 2)}
|
|
861
|
-
`, { mode: 384 });
|
|
862
|
-
}
|
|
863
|
-
async function upsertProfile(input2) {
|
|
864
|
-
const config = await readConfig();
|
|
865
|
-
config.profiles[input2.name] = {
|
|
866
|
-
apiUrl: input2.apiUrl,
|
|
867
|
-
apiKey: input2.apiKey
|
|
868
|
-
};
|
|
869
|
-
config.currentProfile = input2.name;
|
|
870
|
-
await writeConfig(config);
|
|
871
|
-
}
|
|
872
|
-
async function setCurrentProfile(name) {
|
|
873
|
-
const config = await readConfig();
|
|
874
|
-
if (!config.profiles[name]) {
|
|
875
|
-
throw new Error(`Unknown Standards instance "${name}"`);
|
|
876
|
-
}
|
|
877
|
-
config.currentProfile = name;
|
|
878
|
-
await writeConfig(config);
|
|
879
|
-
}
|
|
880
|
-
async function removeProfile(name) {
|
|
881
|
-
const config = await readConfig();
|
|
882
|
-
const profileName = name ?? config.currentProfile;
|
|
883
|
-
if (!profileName) return;
|
|
884
|
-
delete config.profiles[profileName];
|
|
885
|
-
if (config.currentProfile === profileName) {
|
|
886
|
-
config.currentProfile = void 0;
|
|
887
|
-
}
|
|
888
|
-
await writeConfig(config);
|
|
889
|
-
}
|
|
890
|
-
async function listProfiles() {
|
|
891
|
-
const config = await readConfig();
|
|
892
|
-
const profiles = Object.entries(config.profiles).sort(([a], [b]) => a.localeCompare(b)).map(([name, profile]) => ({
|
|
893
|
-
name,
|
|
894
|
-
apiUrl: profile.apiUrl,
|
|
895
|
-
current: config.currentProfile === name
|
|
896
|
-
}));
|
|
897
|
-
const envProfile = getEnvProfile();
|
|
898
|
-
if (envProfile) {
|
|
899
|
-
profiles.push({
|
|
900
|
-
name: ENV_PROFILE_NAME,
|
|
901
|
-
apiUrl: envProfile.apiUrl,
|
|
902
|
-
current: !config.currentProfile,
|
|
903
|
-
source: "env"
|
|
904
|
-
});
|
|
905
|
-
}
|
|
906
|
-
return profiles;
|
|
907
|
-
}
|
|
908
|
-
async function getActiveProfile() {
|
|
909
|
-
const config = await readConfig();
|
|
910
|
-
if (!config.currentProfile) {
|
|
911
|
-
const envProfile = getEnvProfile();
|
|
912
|
-
return envProfile ? { name: ENV_PROFILE_NAME, ...envProfile } : null;
|
|
913
|
-
}
|
|
914
|
-
const profile = config.profiles[config.currentProfile];
|
|
915
|
-
if (!profile) return null;
|
|
916
|
-
return { name: config.currentProfile, ...profile };
|
|
917
|
-
}
|
|
918
|
-
function getEnvProfile() {
|
|
919
|
-
if (!process.env.STANDARDS_API_KEY) return null;
|
|
920
|
-
return {
|
|
921
|
-
apiUrl: process.env.STANDARDS_API_URL ?? DEFAULT_API_URL,
|
|
922
|
-
apiKey: process.env.STANDARDS_API_KEY
|
|
923
|
-
};
|
|
924
|
-
}
|
|
925
|
-
async function resolveCliConfig(options) {
|
|
926
|
-
if (options.apiUrl || options.apiKey) {
|
|
927
|
-
return {
|
|
928
|
-
apiUrl: options.apiUrl ?? process.env.STANDARDS_API_URL ?? DEFAULT_API_URL,
|
|
929
|
-
apiKey: options.apiKey ?? process.env.STANDARDS_API_KEY
|
|
930
|
-
};
|
|
931
|
-
}
|
|
932
|
-
const config = await readConfig();
|
|
933
|
-
const profileName = options.instance ?? config.currentProfile;
|
|
934
|
-
if (profileName) {
|
|
935
|
-
const profile = config.profiles[profileName];
|
|
936
|
-
if (!profile) throw new Error(`Unknown Standards instance "${profileName}"`);
|
|
937
|
-
return {
|
|
938
|
-
apiUrl: profile.apiUrl,
|
|
939
|
-
apiKey: profile.apiKey,
|
|
940
|
-
profileName
|
|
941
|
-
};
|
|
942
|
-
}
|
|
943
|
-
return {
|
|
944
|
-
apiUrl: process.env.STANDARDS_API_URL ?? DEFAULT_API_URL,
|
|
945
|
-
apiKey: process.env.STANDARDS_API_KEY
|
|
946
|
-
};
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
// src/commands/root.ts
|
|
950
1756
|
async function promptForMissingValue(label) {
|
|
951
1757
|
const rl = createInterface({ input, output });
|
|
952
1758
|
try {
|
|
@@ -1054,6 +1860,7 @@ function createProgram() {
|
|
|
1054
1860
|
registerSchemaCommand(program);
|
|
1055
1861
|
registerPullCommand(program);
|
|
1056
1862
|
registerDocumentsCommand(program);
|
|
1863
|
+
registerDeviceCommand(program);
|
|
1057
1864
|
registerFoldersCommand(program);
|
|
1058
1865
|
registerKeysCommand(program);
|
|
1059
1866
|
registerAuthCommand(program);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stndrds/cli",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.293",
|
|
4
4
|
"description": "CLI tool to interact with Standards API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"chalk": "^5.4.1",
|
|
14
14
|
"cli-table3": "^0.6.5",
|
|
15
15
|
"commander": "^13.1.0",
|
|
16
|
-
"@stndrds/schema": "1.0.0-alpha.
|
|
16
|
+
"@stndrds/schema": "1.0.0-alpha.293"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@types/node": "^25.6.0",
|