@peakinc/cli 0.0.1

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/dist/client.js ADDED
@@ -0,0 +1,128 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, lstat, open } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ export class ClientError extends Error {
6
+ code;
7
+ retryAfter;
8
+ constructor(code, message, retryAfter) {
9
+ super(message);
10
+ this.code = code;
11
+ this.retryAfter = retryAfter;
12
+ }
13
+ }
14
+ export function apiOrigin(value) {
15
+ let url;
16
+ try {
17
+ url = new URL(value);
18
+ }
19
+ catch {
20
+ throw new ClientError('invalid_origin', 'Use an HTTPS API origin.');
21
+ }
22
+ if ((url.protocol !== 'https:' &&
23
+ !(url.protocol === 'http:' &&
24
+ ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) ||
25
+ url.username ||
26
+ url.password ||
27
+ url.pathname !== '/' ||
28
+ url.search ||
29
+ url.hash) {
30
+ throw new ClientError('invalid_origin', 'Use an HTTPS API origin, or localhost for development.');
31
+ }
32
+ return url.origin;
33
+ }
34
+ function credentialFile(origin, directory) {
35
+ return join(directory, `${createHash('sha256').update(origin).digest('hex')}.json`);
36
+ }
37
+ export function configDirectory() {
38
+ return process.env.PEAK_CONFIG_DIR || join(homedir(), '.config', 'peak');
39
+ }
40
+ export async function readCredential(origin, directory = configDirectory()) {
41
+ const path = credentialFile(origin, directory);
42
+ try {
43
+ const stat = await lstat(path);
44
+ if (stat.isSymbolicLink() ||
45
+ (process.platform !== 'win32' && stat.mode & 0o077)) {
46
+ throw new ClientError('unsafe_credentials', 'Credential file must be private (mode 600).');
47
+ }
48
+ const data = JSON.parse(await readFile(path, 'utf8'));
49
+ if (data.origin !== origin ||
50
+ typeof data.access_token !== 'string' ||
51
+ typeof data.expires_at !== 'string' ||
52
+ typeof data.token_id !== 'string') {
53
+ throw new Error('Invalid credential file');
54
+ }
55
+ return {
56
+ access_token: data.access_token,
57
+ token_id: data.token_id,
58
+ expires_at: data.expires_at,
59
+ };
60
+ }
61
+ catch (error) {
62
+ if (error.code === 'ENOENT')
63
+ return null;
64
+ if (error instanceof ClientError)
65
+ throw error;
66
+ throw new ClientError('invalid_credentials', 'Credential file is invalid. Remove it and log in again.');
67
+ }
68
+ }
69
+ export async function saveCredential(origin, credential, directory = configDirectory()) {
70
+ await mkdir(directory, { recursive: true, mode: 0o700 });
71
+ if ((await lstat(directory)).isSymbolicLink())
72
+ throw new ClientError('unsafe_credentials', 'Credential directory cannot be a symlink.');
73
+ const path = credentialFile(origin, directory), temporary = `${path}.${randomUUID()}.tmp`;
74
+ try {
75
+ const file = await open(temporary, 'wx', 0o600);
76
+ try {
77
+ await file.writeFile(JSON.stringify({ origin, ...credential }) + '\n');
78
+ await file.sync();
79
+ }
80
+ finally {
81
+ await file.close();
82
+ }
83
+ await rename(temporary, path);
84
+ }
85
+ finally {
86
+ await rm(temporary, { force: true });
87
+ }
88
+ }
89
+ export async function removeCredential(origin, directory = configDirectory()) {
90
+ await rm(credentialFile(origin, directory), { force: true });
91
+ }
92
+ export async function request(origin, path, options = {}) {
93
+ let response;
94
+ try {
95
+ response = await fetch(`${origin}${path}`, {
96
+ method: options.method ?? 'GET',
97
+ redirect: 'error',
98
+ signal: AbortSignal.timeout(15_000),
99
+ headers: {
100
+ Accept: 'application/json',
101
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
102
+ ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
103
+ },
104
+ body: options.body ? JSON.stringify(options.body) : undefined,
105
+ });
106
+ }
107
+ catch {
108
+ throw new ClientError('connection_failed', 'Cannot reach Peak. Check the API URL and connection.');
109
+ }
110
+ let result;
111
+ try {
112
+ result = await response.json();
113
+ }
114
+ catch {
115
+ throw new ClientError('invalid_response', 'Peak returned an invalid response.');
116
+ }
117
+ if (!response.ok) {
118
+ const code = typeof result.error?.code === 'string'
119
+ ? result.error.code
120
+ : 'request_failed';
121
+ const message = typeof result.error?.message === 'string'
122
+ ? result.error.message
123
+ : 'Request failed.';
124
+ const retryAfter = Number(response.headers.get('retry-after'));
125
+ throw new ClientError(code, message.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 300), retryAfter || undefined);
126
+ }
127
+ return result;
128
+ }
package/dist/main.js ADDED
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { hostname } from 'node:os';
4
+ import { spawn } from 'node:child_process';
5
+ import { setTimeout as sleep } from 'node:timers/promises';
6
+ import { parseArgs } from 'node:util';
7
+ import { previewWorkflowFile } from './workflow.js';
8
+ import { apiOrigin, ClientError, readCredential, removeCredential, request, saveCredential, } from './client.js';
9
+ const { values, positionals } = parseArgs({
10
+ allowPositionals: true,
11
+ options: {
12
+ 'api-url': { type: 'string' },
13
+ json: { type: 'boolean' },
14
+ 'no-browser': { type: 'boolean' },
15
+ name: { type: 'string' },
16
+ job: { type: 'string' },
17
+ runner: { type: 'string' },
18
+ runs: { type: 'boolean' },
19
+ repository: { type: 'string' },
20
+ branch: { type: 'string' },
21
+ status: { type: 'string' },
22
+ architecture: { type: 'string' },
23
+ limit: { type: 'string' },
24
+ after: { type: 'string' },
25
+ help: { type: 'boolean', short: 'h' },
26
+ },
27
+ });
28
+ const json = Boolean(values.json);
29
+ const output = (value, message) => console.log(json ? JSON.stringify(value) : message);
30
+ async function main() {
31
+ const [command = 'help', subcommand] = positionals;
32
+ if (values.help || command === 'help') {
33
+ console.log('Peak\n\n peak login Connect this device\n peak login --runs Include run history access\n peak status Show your workspace\n peak logout Revoke this device’s token\n peak api workspace Read workspace details\n peak api audit-events Read activity (audit:read)\n peak api runner-types List released runner types\n peak runners list List configured runners\n peak runs list List jobs (runs:read)\n peak runs view <id> Show a job (runs:read)\n peak workflow preview <file> --job <id> --runner <label>\n Preview a workflow change\n\n Run filters: --repository <id>, --branch <name>,\n --status <in_progress|completed>, --architecture <x64|arm64>\n Pagination: --limit <1-100>, --after <cursor>\n\n --json Machine-readable output\n --no-browser Print the approval URL\n --api-url <origin> API origin (or PEAK_API_URL)\n\nAgents can supply PEAK_TOKEN instead of logging in.');
34
+ return;
35
+ }
36
+ const origin = apiOrigin(values['api-url'] ?? process.env.PEAK_API_URL ?? 'https://staging.peak.inc');
37
+ if (command === 'login') {
38
+ const previous = await readCredential(origin);
39
+ const verifier = randomBytes(32).toString('base64url');
40
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
41
+ const started = await request(origin, '/api/v1/device/authorize', {
42
+ method: 'POST',
43
+ body: {
44
+ name: values.name ?? `Terminal · ${hostname()}`.slice(0, 64),
45
+ code_challenge: challenge,
46
+ scopes: values.runs
47
+ ? ['workspace:read', 'runs:read']
48
+ : ['workspace:read'],
49
+ },
50
+ });
51
+ // Never launch a URL outside the configured Peak origin.
52
+ const verification = new URL(started.verification_uri_complete);
53
+ if (verification.origin !== origin ||
54
+ verification.pathname !== '/agent/authorize')
55
+ throw new ClientError('invalid_response', 'Invalid approval URL.');
56
+ output({
57
+ event: 'authorization_required',
58
+ verification_url: verification.href,
59
+ user_code: started.user_code,
60
+ expires_in: started.expires_in,
61
+ }, `Open ${verification.href}\n\nCode: ${started.user_code}\nWaiting for approval…`);
62
+ if (!json &&
63
+ !values['no-browser'] &&
64
+ process.stderr.isTTY &&
65
+ ['darwin', 'linux'].includes(process.platform)) {
66
+ const child = spawn(process.platform === 'darwin' ? 'open' : 'xdg-open', [verification.href], { stdio: 'ignore', detached: true });
67
+ child.on('error', () => { });
68
+ child.unref();
69
+ }
70
+ const deadline = Date.now() + Math.min(started.expires_in, 600) * 1000;
71
+ let interval = Math.max(5, started.interval);
72
+ while (Date.now() < deadline) {
73
+ await sleep(interval * 1000);
74
+ let credential;
75
+ try {
76
+ credential = await request(origin, '/api/v1/device/token', {
77
+ method: 'POST',
78
+ body: { device_code: started.device_code, code_verifier: verifier },
79
+ });
80
+ }
81
+ catch (error) {
82
+ if (error instanceof ClientError &&
83
+ ['authorization_pending', 'slow_down'].includes(error.code)) {
84
+ interval = Math.max(interval, error.retryAfter ??
85
+ (error.code === 'slow_down' ? interval + 5 : interval));
86
+ continue;
87
+ }
88
+ throw error;
89
+ }
90
+ try {
91
+ await saveCredential(origin, credential);
92
+ }
93
+ catch (error) {
94
+ await request(origin, '/api/v1/token', {
95
+ method: 'DELETE',
96
+ token: credential.access_token,
97
+ }).catch(() => { });
98
+ throw error;
99
+ }
100
+ if (previous)
101
+ await request(origin, '/api/v1/token', {
102
+ method: 'DELETE',
103
+ token: previous.access_token,
104
+ }).catch(() => { });
105
+ const workspace = await request(origin, '/api/v1/workspace', { token: credential.access_token });
106
+ output({
107
+ event: 'authenticated',
108
+ workspace,
109
+ token_id: credential.token_id,
110
+ expires_at: credential.expires_at,
111
+ }, `Connected to ${workspace.name}.`);
112
+ return;
113
+ }
114
+ throw new ClientError('expired_token', 'Login expired. Run peak login again.');
115
+ }
116
+ const stored = process.env.PEAK_TOKEN ? null : await readCredential(origin);
117
+ const token = process.env.PEAK_TOKEN || stored?.access_token;
118
+ if (!token)
119
+ throw new ClientError('not_authenticated', 'Run peak login or set PEAK_TOKEN.');
120
+ if (command === 'logout') {
121
+ try {
122
+ await request(origin, '/api/v1/token', { method: 'DELETE', token });
123
+ }
124
+ catch (error) {
125
+ if (!(error instanceof ClientError && error.code === 'unauthorized'))
126
+ throw error;
127
+ }
128
+ if (!process.env.PEAK_TOKEN)
129
+ await removeCredential(origin);
130
+ output({ event: 'signed_out' }, 'Signed out.');
131
+ return;
132
+ }
133
+ if (command === 'status' ||
134
+ (command === 'api' && subcommand === 'workspace')) {
135
+ const workspace = await request(origin, '/api/v1/workspace', { token });
136
+ output(workspace, `${workspace.name}\n${workspace.id}`);
137
+ return;
138
+ }
139
+ if (command === 'api' && subcommand === 'audit-events') {
140
+ const events = await request(origin, '/api/v1/audit-events', {
141
+ token,
142
+ });
143
+ console.log(JSON.stringify(events, null, json ? undefined : 2));
144
+ return;
145
+ }
146
+ if (command === 'api' && subcommand === 'runner-types') {
147
+ const runners = await request(origin, '/api/v1/runner-types', {
148
+ token,
149
+ });
150
+ console.log(JSON.stringify(runners, null, json ? undefined : 2));
151
+ return;
152
+ }
153
+ if (command === 'runs') {
154
+ let path;
155
+ if (subcommand === 'list' && positionals.length === 2) {
156
+ const params = new URLSearchParams();
157
+ for (const key of [
158
+ 'repository',
159
+ 'branch',
160
+ 'status',
161
+ 'architecture',
162
+ 'limit',
163
+ 'after',
164
+ ]) {
165
+ const value = values[key];
166
+ if (value !== undefined)
167
+ params.set(key === 'repository' ? 'repository_id' : key, value);
168
+ }
169
+ path = `/api/v1/runs?${params}`;
170
+ }
171
+ else if (subcommand === 'view' &&
172
+ positionals.length === 3 &&
173
+ /^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/.test(positionals[2])) {
174
+ path = `/api/v1/runs/${positionals[2]}`;
175
+ }
176
+ else {
177
+ throw new ClientError('invalid_arguments', 'Use peak runs list or peak runs view <id>.');
178
+ }
179
+ const result = await request(origin, path, { token });
180
+ console.log(JSON.stringify(result, null, json ? undefined : 2));
181
+ return;
182
+ }
183
+ if (command === 'runners' && subcommand === 'list') {
184
+ if (positionals.length !== 2)
185
+ throw new ClientError('invalid_arguments', 'Use peak runners list.');
186
+ const params = new URLSearchParams();
187
+ if (values.limit !== undefined)
188
+ params.set('limit', values.limit);
189
+ if (values.after !== undefined)
190
+ params.set('after', values.after);
191
+ const result = await request(origin, `/api/v1/runners${params.size ? `?${params}` : ''}`, { token });
192
+ console.log(JSON.stringify(result, null, json ? undefined : 2));
193
+ return;
194
+ }
195
+ if (command === 'workflow' && subcommand === 'preview') {
196
+ if (positionals.length !== 3 || !values.job || !values.runner)
197
+ throw new ClientError('invalid_arguments', 'Use peak workflow preview <file> --job <id> --runner <label>.');
198
+ const preview = await previewWorkflowFile(origin, positionals[2], values.job, values.runner, token);
199
+ output(preview, preview.changed
200
+ ? `${preview.job}\n- runs-on: ${preview.change.before}\n+ runs-on: ${preview.change.after}\n\nPreview only. File unchanged.`
201
+ : `${preview.job}: already configured. File unchanged.`);
202
+ return;
203
+ }
204
+ throw new ClientError('unknown_command', 'Unknown command. Run peak --help.');
205
+ }
206
+ main().catch((error) => {
207
+ const code = error instanceof ClientError ? error.code : 'unexpected_error';
208
+ const message = error instanceof ClientError
209
+ ? error.message
210
+ : 'Peak could not complete the request.';
211
+ console.error(json ? JSON.stringify({ error: { code, message } }) : message);
212
+ process.exitCode = 1;
213
+ });
@@ -0,0 +1,70 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { open } from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import { ClientError, request } from './client.js';
5
+ export async function previewWorkflowFile(origin, path, job, runner, token) {
6
+ let workflow;
7
+ try {
8
+ const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
9
+ try {
10
+ const stat = await file.stat();
11
+ if (!stat.isFile() || stat.size > 128 * 1024)
12
+ throw new Error();
13
+ const bytes = Buffer.alloc(128 * 1024 + 1);
14
+ let length = 0;
15
+ while (length < bytes.length) {
16
+ const read = await file.read(bytes, length, bytes.length - length, length);
17
+ if (!read.bytesRead)
18
+ break;
19
+ length += read.bytesRead;
20
+ }
21
+ if (length > 128 * 1024)
22
+ throw new Error();
23
+ workflow = new TextDecoder('utf-8', {
24
+ fatal: true,
25
+ ignoreBOM: true,
26
+ }).decode(bytes.subarray(0, length));
27
+ }
28
+ finally {
29
+ await file.close();
30
+ }
31
+ }
32
+ catch {
33
+ throw new ClientError('invalid_workflow_file', 'Use a UTF-8 workflow file of up to 128 KiB.');
34
+ }
35
+ const preview = await request(origin, '/api/v1/workflow-preview', {
36
+ method: 'POST',
37
+ token,
38
+ body: { workflow, job, runner_label: runner },
39
+ });
40
+ const digest = (value) => createHash('sha256').update(value).digest('hex');
41
+ const change = preview?.change;
42
+ const rollback = preview?.rollback;
43
+ if (preview?.object !== 'workflow_preview' ||
44
+ preview.job !== job ||
45
+ preview.runner?.label !== runner ||
46
+ preview.source_sha256 !== digest(workflow) ||
47
+ typeof preview.updated_workflow !== 'string' ||
48
+ preview.updated_sha256 !== digest(preview.updated_workflow) ||
49
+ !change ||
50
+ !Number.isInteger(change.start) ||
51
+ !Number.isInteger(change.end) ||
52
+ change.start < 0 ||
53
+ change.end < change.start ||
54
+ change.end > workflow.length ||
55
+ typeof change.before !== 'string' ||
56
+ typeof change.after !== 'string' ||
57
+ workflow.slice(change.start, change.end) !== change.before ||
58
+ workflow.slice(0, change.start) +
59
+ change.after +
60
+ workflow.slice(change.end) !==
61
+ preview.updated_workflow ||
62
+ preview.changed !== (change.before !== change.after) ||
63
+ !rollback ||
64
+ rollback.start !== change.start ||
65
+ rollback.end !== change.start + change.after.length ||
66
+ rollback.before !== change.after ||
67
+ rollback.after !== change.before)
68
+ throw new ClientError('invalid_response', 'Peak returned an invalid workflow preview.');
69
+ return preview;
70
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@peakinc/cli",
3
+ "version": "0.0.1",
4
+ "description": "Peak command line: log in, read your workspace, preview a workflow change.",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/PeakInc/source.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "homepage": "https://peak.inc",
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "type": "module",
16
+ "engines": {
17
+ "node": ">=22.13.0"
18
+ },
19
+ "bin": {
20
+ "peak": "dist/main.js"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.json",
27
+ "prepublishOnly": "npm run build"
28
+ }
29
+ }