sealcode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/bin/sealcode.js +11 -0
- package/package.json +53 -0
- package/src/api.js +162 -0
- package/src/bundle.js +133 -0
- package/src/cli-auth.js +233 -0
- package/src/cli-grants.js +185 -0
- package/src/cli-link.js +90 -0
- package/src/cli.js +683 -0
- package/src/config.js +76 -0
- package/src/crypto.js +151 -0
- package/src/errors.js +111 -0
- package/src/hooks.js +127 -0
- package/src/index.js +19 -0
- package/src/init.js +180 -0
- package/src/kdf.js +83 -0
- package/src/keystore.js +249 -0
- package/src/link-state.js +60 -0
- package/src/manifest.js +25 -0
- package/src/open.js +43 -0
- package/src/presets.js +338 -0
- package/src/prompt.js +108 -0
- package/src/recovery.js +154 -0
- package/src/seal.js +174 -0
- package/src/status.js +207 -0
- package/src/ui.js +270 -0
- package/src/util.js +59 -0
- package/src/verify.js +51 -0
package/src/cli-auth.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `sealcode login` / `sealcode signout` / `sealcode whoami`.
|
|
5
|
+
*
|
|
6
|
+
* Login flow is OAuth-style device pairing:
|
|
7
|
+
* 1. POST /api/v1/cli/pair/start with hostname/platform/cliVersion.
|
|
8
|
+
* 2. Print the verification URL + user code.
|
|
9
|
+
* 3. POST /api/v1/cli/pair/poll every few seconds until status is terminal.
|
|
10
|
+
* 4. On `approved`, persist the returned bearer token to
|
|
11
|
+
* ~/.sealcode/credentials.json (0600), then verify with GET /cli/me.
|
|
12
|
+
*
|
|
13
|
+
* The user can ctrl-C at any time; we never leave a half-finished credential
|
|
14
|
+
* on disk.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
ApiError,
|
|
19
|
+
clientInfo,
|
|
20
|
+
clearCreds,
|
|
21
|
+
defaultApiUrl,
|
|
22
|
+
getApiUrl,
|
|
23
|
+
readCreds,
|
|
24
|
+
request,
|
|
25
|
+
writeCreds,
|
|
26
|
+
} = require('./api');
|
|
27
|
+
const ui = require('./ui');
|
|
28
|
+
|
|
29
|
+
function sleep(ms) {
|
|
30
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function runLogin({ apiUrl } = {}) {
|
|
34
|
+
const baseUrl = apiUrl || process.env.SEALCODE_API_URL || defaultApiUrl();
|
|
35
|
+
const existing = readCreds();
|
|
36
|
+
if (existing && existing.token) {
|
|
37
|
+
process.stdout.write(
|
|
38
|
+
`Already logged in as ${existing.email || existing.userId} at ${existing.apiUrl}.\n`
|
|
39
|
+
+ 'Run `sealcode signout` first if you want to switch accounts.\n',
|
|
40
|
+
);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const info = clientInfo();
|
|
45
|
+
let start;
|
|
46
|
+
try {
|
|
47
|
+
start = await request('POST', '/api/v1/cli/pair/start', {
|
|
48
|
+
body: info,
|
|
49
|
+
apiUrl: baseUrl,
|
|
50
|
+
});
|
|
51
|
+
} catch (err) {
|
|
52
|
+
rethrow('start pairing', err);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!start || !start.deviceCode || !start.verifyUrl) {
|
|
56
|
+
throw new Error('Server returned an unexpected pairing response.');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const verifyUrl = start.verifyUrl;
|
|
60
|
+
const userCode = start.userCode;
|
|
61
|
+
const pollIntervalMs = Math.max(1000, (start.pollIntervalSeconds || 3) * 1000);
|
|
62
|
+
const expiresAt = start.expiresAt ? Date.parse(start.expiresAt) : Date.now() + 10 * 60 * 1000;
|
|
63
|
+
|
|
64
|
+
ui.box('Pair this device', [
|
|
65
|
+
ui.c.dim('Open this URL in a browser where you are logged in to sealcode:'),
|
|
66
|
+
'',
|
|
67
|
+
` ${ui.c.cyan(verifyUrl)}`,
|
|
68
|
+
'',
|
|
69
|
+
`${ui.c.dim('pairing code:')} ${ui.c.bold(userCode)}`,
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
const sp = new ui.Spinner('Waiting for approval').start();
|
|
73
|
+
let token = null;
|
|
74
|
+
while (Date.now() < expiresAt) {
|
|
75
|
+
const remainingSec = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
|
|
76
|
+
const mm = Math.floor(remainingSec / 60);
|
|
77
|
+
const ss = String(remainingSec % 60).padStart(2, '0');
|
|
78
|
+
sp.update(`Waiting for approval ${ui.c.dim(`(${mm}:${ss} left)`)}`);
|
|
79
|
+
await sleep(pollIntervalMs);
|
|
80
|
+
|
|
81
|
+
let poll;
|
|
82
|
+
try {
|
|
83
|
+
poll = await request('POST', '/api/v1/cli/pair/poll', {
|
|
84
|
+
body: { deviceCode: start.deviceCode },
|
|
85
|
+
apiUrl: baseUrl,
|
|
86
|
+
});
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// Transient network blips shouldn't kill the whole login; rate-limits
|
|
89
|
+
// and 5xx are worth surfacing immediately though.
|
|
90
|
+
if (err instanceof ApiError && err.status === 429) {
|
|
91
|
+
await sleep(2000);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (err instanceof ApiError && err.status >= 500) {
|
|
95
|
+
sp.fail('Server error while polling');
|
|
96
|
+
rethrow('poll pairing', err);
|
|
97
|
+
}
|
|
98
|
+
if (err instanceof ApiError && err.status === 0) {
|
|
99
|
+
await sleep(2000);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
sp.fail('Polling failed');
|
|
103
|
+
rethrow('poll pairing', err);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (poll.status === 'pending') continue;
|
|
107
|
+
if (poll.status === 'denied') {
|
|
108
|
+
sp.fail('Pairing was denied');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (poll.status === 'expired') {
|
|
112
|
+
sp.fail('Pairing code expired — run `sealcode login` again');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (poll.status === 'approved') {
|
|
116
|
+
if (!poll.token) {
|
|
117
|
+
sp.fail('Approved on the server but token already consumed — run `sealcode login` again');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
token = poll.token;
|
|
121
|
+
sp.succeed('Approved');
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!token) {
|
|
127
|
+
sp.fail('Pairing timed out — run `sealcode login` again');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Persist the token before calling /me — that way a network blip after we
|
|
132
|
+
// got the token doesn't lose it.
|
|
133
|
+
writeCreds({
|
|
134
|
+
apiUrl: baseUrl,
|
|
135
|
+
token,
|
|
136
|
+
userId: null,
|
|
137
|
+
email: null,
|
|
138
|
+
name: null,
|
|
139
|
+
createdAt: new Date().toISOString(),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
let me;
|
|
143
|
+
try {
|
|
144
|
+
me = await ui.withSpinner('Fetching profile', () =>
|
|
145
|
+
request('GET', '/api/v1/cli/me', { auth: true, apiUrl: baseUrl }),
|
|
146
|
+
);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
// Don't wipe the token just because /me hiccuped; the token is good.
|
|
149
|
+
if (err instanceof ApiError && err.status === 0) {
|
|
150
|
+
ui.ok(`Logged in. ${ui.c.dim(`(could not fetch profile: ${err.message})`)}`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
rethrow('fetch profile', err);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const user = (me && me.user) || {};
|
|
157
|
+
writeCreds({
|
|
158
|
+
apiUrl: baseUrl,
|
|
159
|
+
token,
|
|
160
|
+
userId: user.id || null,
|
|
161
|
+
email: user.email || null,
|
|
162
|
+
name: user.name || null,
|
|
163
|
+
createdAt: new Date().toISOString(),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
ui.ok(`Logged in as ${ui.c.bold(user.email || user.id || 'unknown')} ${ui.c.dim(`at ${baseUrl}`)}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function runSignout() {
|
|
170
|
+
const creds = readCreds();
|
|
171
|
+
if (!creds || !creds.token) {
|
|
172
|
+
process.stdout.write('Not logged in.\n');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Best-effort server-side revoke. We still clear the local token even if
|
|
176
|
+
// the server says no, so `signout` is never useless.
|
|
177
|
+
try {
|
|
178
|
+
await request('POST', '/api/v1/cli/logout', { auth: true });
|
|
179
|
+
} catch (err) {
|
|
180
|
+
process.stderr.write(
|
|
181
|
+
`(warning: server revoke failed: ${err.message || err}. Clearing local credentials anyway.)\n`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
clearCreds();
|
|
185
|
+
ui.ok('Signed out.');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function runWhoami({ verbose = false } = {}) {
|
|
189
|
+
const creds = readCreds();
|
|
190
|
+
if (!creds || !creds.token) {
|
|
191
|
+
process.stdout.write('Not logged in. Run `sealcode login`.\n');
|
|
192
|
+
process.exitCode = 1;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// If we can talk to the server, prefer fresh data; otherwise fall back to
|
|
196
|
+
// the cached creds so `whoami` works offline.
|
|
197
|
+
let user = null;
|
|
198
|
+
let online = true;
|
|
199
|
+
try {
|
|
200
|
+
const me = await request('GET', '/api/v1/cli/me', { auth: true });
|
|
201
|
+
user = me.user || null;
|
|
202
|
+
} catch (err) {
|
|
203
|
+
online = false;
|
|
204
|
+
if (verbose) {
|
|
205
|
+
process.stderr.write(`(offline: ${err.message || err})\n`);
|
|
206
|
+
}
|
|
207
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
208
|
+
process.stdout.write(
|
|
209
|
+
'✗ The server rejected your token (revoked or deleted). Run `sealcode login`.\n',
|
|
210
|
+
);
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const email = (user && user.email) || creds.email || '(unknown email)';
|
|
217
|
+
const id = (user && user.id) || creds.userId || '(unknown id)';
|
|
218
|
+
process.stdout.write(`logged in as ${email}\n`);
|
|
219
|
+
if (verbose) {
|
|
220
|
+
process.stdout.write(` user id: ${id}\n`);
|
|
221
|
+
process.stdout.write(` api url: ${getApiUrl()}\n`);
|
|
222
|
+
process.stdout.write(` status: ${online ? 'online' : 'offline (cached)'}\n`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function rethrow(stage, err) {
|
|
227
|
+
if (err instanceof ApiError) {
|
|
228
|
+
throw new Error(`Could not ${stage} (${err.status} ${err.apiCode}): ${err.message}`);
|
|
229
|
+
}
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = { runLogin, runSignout, runWhoami };
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Temporary access codes from the CLI.
|
|
5
|
+
*
|
|
6
|
+
* sealcode share → POST /api/v1/projects/:id/grants (owner side)
|
|
7
|
+
* sealcode grants → GET /api/v1/projects/:id/grants (owner side)
|
|
8
|
+
* sealcode revoke → DELETE /api/v1/grants/:id (owner side)
|
|
9
|
+
* sealcode redeem → POST /api/v1/access/redeem (recipient side)
|
|
10
|
+
*
|
|
11
|
+
* `share` and `revoke` require the project to be linked first
|
|
12
|
+
* (`sealcode link <projectId>`); `redeem` is the only command in this file
|
|
13
|
+
* that doesn't need auth — it's used by the developer receiving the code.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { ApiError, clientInfo, request } = require('./api');
|
|
17
|
+
const { getLink } = require('./link-state');
|
|
18
|
+
const ui = require('./ui');
|
|
19
|
+
|
|
20
|
+
function requireLink(projectRoot) {
|
|
21
|
+
const link = getLink(projectRoot);
|
|
22
|
+
if (!link) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
'This project is not linked. Run `sealcode link <projectId>` first.',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return link;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatRemaining(ms) {
|
|
31
|
+
if (ms <= 0) return 'expired';
|
|
32
|
+
const sec = Math.floor(ms / 1000);
|
|
33
|
+
if (sec < 60) return `${sec}s`;
|
|
34
|
+
const min = Math.floor(sec / 60);
|
|
35
|
+
if (min < 60) return `${min}m`;
|
|
36
|
+
const hr = Math.floor(min / 60);
|
|
37
|
+
if (hr < 48) return `${hr}h ${min % 60}m`;
|
|
38
|
+
return `${Math.floor(hr / 24)}d ${hr % 24}h`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function runShare({
|
|
42
|
+
projectRoot,
|
|
43
|
+
hours,
|
|
44
|
+
email,
|
|
45
|
+
label,
|
|
46
|
+
autoLock = true,
|
|
47
|
+
autoLockOffsetMinutes = 0,
|
|
48
|
+
strict = false,
|
|
49
|
+
heartbeatSec = 300,
|
|
50
|
+
offlineGraceSec = 1800,
|
|
51
|
+
json = false,
|
|
52
|
+
}) {
|
|
53
|
+
const link = requireLink(projectRoot);
|
|
54
|
+
const body = {
|
|
55
|
+
developerEmail: email || '',
|
|
56
|
+
developerLabel: label || '',
|
|
57
|
+
expiresInHours: hours,
|
|
58
|
+
autoLockEnabled: !!autoLock,
|
|
59
|
+
autoLockOffsetMinutes: Math.max(0, Math.floor(autoLockOffsetMinutes || 0)),
|
|
60
|
+
strictMode: !!strict,
|
|
61
|
+
heartbeatIntervalSeconds: Math.max(30, Math.floor(heartbeatSec)),
|
|
62
|
+
offlineGraceSeconds: Math.max(60, Math.floor(offlineGraceSec)),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
let res;
|
|
66
|
+
try {
|
|
67
|
+
res = await request(
|
|
68
|
+
'POST',
|
|
69
|
+
`/api/v1/projects/${encodeURIComponent(link.projectId)}/grants`,
|
|
70
|
+
{ auth: true, body },
|
|
71
|
+
);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err instanceof ApiError && err.status === 402) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
'Temporary access codes are a Pro feature. Upgrade in Billing on the dashboard.',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const g = res.grant;
|
|
82
|
+
if (json) {
|
|
83
|
+
process.stdout.write(
|
|
84
|
+
JSON.stringify(
|
|
85
|
+
{
|
|
86
|
+
id: g.id,
|
|
87
|
+
code: g.code,
|
|
88
|
+
codePrefix: g.codePrefix,
|
|
89
|
+
projectId: g.projectId,
|
|
90
|
+
expiresAt: g.expiresAt,
|
|
91
|
+
},
|
|
92
|
+
null,
|
|
93
|
+
2,
|
|
94
|
+
) + '\n',
|
|
95
|
+
);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const meta = [
|
|
100
|
+
`${ui.c.dim('project ')} ${ui.c.bold(link.projectName || link.projectId)}`,
|
|
101
|
+
`${ui.c.dim('expires ')} ${g.expiresAt}`,
|
|
102
|
+
];
|
|
103
|
+
if (g.autoLockAt) meta.push(`${ui.c.dim('auto-lock ')} ${g.autoLockAt}`);
|
|
104
|
+
if (email) meta.push(`${ui.c.dim('email ')} ${email}`);
|
|
105
|
+
|
|
106
|
+
ui.box('Access code — copy now, shown only once', [
|
|
107
|
+
` ${ui.c.bold(ui.c.green(g.code))}`,
|
|
108
|
+
'',
|
|
109
|
+
...meta,
|
|
110
|
+
], { tone: 'green' });
|
|
111
|
+
|
|
112
|
+
ui.hint('Share it with the developer over a secure channel. They redeem with:');
|
|
113
|
+
process.stdout.write(` ${ui.c.cyan(`sealcode redeem ${g.code}`)}\n\n`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function runGrants({ projectRoot, json = false }) {
|
|
117
|
+
const link = requireLink(projectRoot);
|
|
118
|
+
const res = await request(
|
|
119
|
+
'GET',
|
|
120
|
+
`/api/v1/projects/${encodeURIComponent(link.projectId)}/grants`,
|
|
121
|
+
{ auth: true },
|
|
122
|
+
);
|
|
123
|
+
const grants = res.grants || [];
|
|
124
|
+
if (json) {
|
|
125
|
+
process.stdout.write(JSON.stringify({ grants }, null, 2) + '\n');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (grants.length === 0) {
|
|
129
|
+
process.stdout.write('No access codes yet. Create one with `sealcode share`.\n');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
process.stdout.write(`\n${link.projectName || link.projectId}: ${grants.length} grant(s)\n`);
|
|
133
|
+
for (const g of grants) {
|
|
134
|
+
const state = g.state.padEnd(8);
|
|
135
|
+
const remaining =
|
|
136
|
+
g.state === 'active' ? formatRemaining(g.remainingMs) : g.state;
|
|
137
|
+
const who = g.developerEmail || g.developerLabel || '(no label)';
|
|
138
|
+
process.stdout.write(
|
|
139
|
+
` ${state} ${g.codePrefix.padEnd(12)} ${remaining.padEnd(10)} ${who} id=${g.id}\n`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
process.stdout.write('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function runRevoke({ grantId }) {
|
|
146
|
+
if (!grantId) throw new Error('Usage: sealcode revoke <grantId>');
|
|
147
|
+
const res = await request(
|
|
148
|
+
'DELETE',
|
|
149
|
+
`/api/v1/grants/${encodeURIComponent(grantId)}`,
|
|
150
|
+
{ auth: true },
|
|
151
|
+
);
|
|
152
|
+
if (res.alreadyTerminal) {
|
|
153
|
+
process.stdout.write(`(grant was already ${res.status}, nothing to do)\n`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
process.stdout.write(`✓ revoked grant ${grantId}\n`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function runRedeem({ code, json = false }) {
|
|
160
|
+
if (!code) throw new Error('Usage: sealcode redeem <CODE>');
|
|
161
|
+
const trimmed = code.trim();
|
|
162
|
+
const res = await request('POST', '/api/v1/access/redeem', {
|
|
163
|
+
body: { code: trimmed, client: clientInfo() },
|
|
164
|
+
});
|
|
165
|
+
const g = res.grant;
|
|
166
|
+
if (json) {
|
|
167
|
+
process.stdout.write(JSON.stringify({ grant: g }, null, 2) + '\n');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
process.stdout.write(`\n✓ access code accepted\n`);
|
|
171
|
+
process.stdout.write(` project: ${g.projectName} (${g.projectId})\n`);
|
|
172
|
+
process.stdout.write(` fingerprint: ${g.projectFingerprint}\n`);
|
|
173
|
+
process.stdout.write(` expires: ${g.expiresAt}\n`);
|
|
174
|
+
if (g.autoLockAt) process.stdout.write(` auto-lock: ${g.autoLockAt}\n`);
|
|
175
|
+
process.stdout.write(` strict: ${g.strictMode ? 'yes' : 'no'}\n`);
|
|
176
|
+
process.stdout.write(
|
|
177
|
+
` heartbeat: every ${g.heartbeatIntervalSeconds}s, ${g.offlineGraceSeconds}s offline grace\n\n`,
|
|
178
|
+
);
|
|
179
|
+
process.stdout.write(
|
|
180
|
+
'Next step (when the owner shares the unlocked vault with you):\n'
|
|
181
|
+
+ ' sealcode unlock\n\n',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = { runShare, runGrants, runRevoke, runRedeem };
|
package/src/cli-link.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `sealcode link <projectId>` / `sealcode unlink`.
|
|
5
|
+
*
|
|
6
|
+
* Associates the current project on disk with a dashboard project record so
|
|
7
|
+
* `sealcode share` knows which project to mint a grant against. We hit
|
|
8
|
+
* GET /api/v1/projects/:id to validate the link before persisting — that way
|
|
9
|
+
* a typo / wrong-account mistake fails before it touches the config.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { ApiError, getApiUrl, request } = require('./api');
|
|
13
|
+
const { getLink, setLink, clearLink } = require('./link-state');
|
|
14
|
+
|
|
15
|
+
async function runLink({ projectRoot, projectId }) {
|
|
16
|
+
if (!projectId || typeof projectId !== 'string') {
|
|
17
|
+
throw new Error('Usage: sealcode link <projectId>');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let res;
|
|
21
|
+
try {
|
|
22
|
+
res = await request('GET', `/api/v1/projects/${encodeURIComponent(projectId)}`, {
|
|
23
|
+
auth: true,
|
|
24
|
+
});
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`No project ${projectId} on this account. Check the project ID in your dashboard.`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
'Not logged in (or your token was revoked). Run `sealcode login` first.',
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const project = res.project;
|
|
40
|
+
setLink(projectRoot, {
|
|
41
|
+
apiUrl: getApiUrl(),
|
|
42
|
+
projectId: project.id,
|
|
43
|
+
projectName: project.name,
|
|
44
|
+
fingerprint: project.fingerprint,
|
|
45
|
+
linkedAt: new Date().toISOString(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
process.stdout.write(
|
|
49
|
+
`✓ Linked ${projectRoot} to project “${project.name}” (${project.id}).\n`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function runUnlink({ projectRoot }) {
|
|
54
|
+
const existing = getLink(projectRoot);
|
|
55
|
+
if (!existing) {
|
|
56
|
+
process.stdout.write('No link to remove.\n');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
clearLink(projectRoot);
|
|
60
|
+
process.stdout.write(
|
|
61
|
+
`✓ Unlinked ${projectRoot} from project ${existing.projectId}.\n`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function runLinkInfo({ projectRoot, json = false }) {
|
|
66
|
+
const link = getLink(projectRoot);
|
|
67
|
+
if (!link) {
|
|
68
|
+
if (json) {
|
|
69
|
+
process.stdout.write(JSON.stringify({ linked: false }) + '\n');
|
|
70
|
+
} else {
|
|
71
|
+
process.stdout.write('This project is not linked. Run `sealcode link <projectId>`.\n');
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (json) {
|
|
76
|
+
process.stdout.write(JSON.stringify({ linked: true, ...link }) + '\n');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
process.stdout.write(
|
|
80
|
+
[
|
|
81
|
+
`linked: yes`,
|
|
82
|
+
`project id: ${link.projectId}`,
|
|
83
|
+
`project name: ${link.projectName || '(unknown)'}`,
|
|
84
|
+
`api url: ${link.apiUrl}`,
|
|
85
|
+
`linked at: ${link.linkedAt || '(unknown)'}`,
|
|
86
|
+
].join('\n') + '\n',
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { runLink, runUnlink, runLinkInfo };
|