sdocs-dev 1.15.0 → 1.19.2
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/bin/sdocs-dev.js +52 -17
- package/lib/agent-block.js +270 -50
- package/lib/agent-files.js +312 -74
- package/lib/cells-verify.js +1 -0
- package/lib/cloud-bindings.js +85 -0
- package/lib/cloud-commands.js +826 -0
- package/lib/cloud-credentials.js +380 -0
- package/lib/commands.js +46 -5
- package/lib/help-text.js +301 -70
- package/lib/io.js +53 -5
- package/lib/library-commands.js +6 -15
- package/lib/library-scan.js +16 -6
- package/lib/library-server.js +4 -12
- package/lib/setup.js +182 -191
- package/lib/short-link.js +38 -1
- package/lib/slides-verify.js +109 -0
- package/package.json +1 -1
- package/shared/sdocs-cells-formula.js +547 -73
- package/shared/sdocs-cells.js +164 -18
- package/shared/sdocs-shapes.js +1044 -0
- package/shared/sdocs-slide-resolve.js +258 -0
- package/shared/sdocs-slide-stdlib.js +180 -0
- package/shared/sdocs-styles.js +1 -1
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const constants = require('./constants');
|
|
6
|
+
const io = require('./io');
|
|
7
|
+
const credentialStore = require('./cloud-credentials');
|
|
8
|
+
const bindings = require('./cloud-bindings');
|
|
9
|
+
const SDocYaml = require('../shared/sdocs-yaml.js');
|
|
10
|
+
|
|
11
|
+
const EXIT = { unexpected: 1, invalid_request: 2, login_required: 3,
|
|
12
|
+
resource_unavailable: 4, account_required: 4, account_selection_required: 4,
|
|
13
|
+
permission_denied: 4, revision_conflict: 5,
|
|
14
|
+
idempotency_mismatch: 5,
|
|
15
|
+
unsafe_local_state: 6, base_revision_unavailable: 6, target_too_old: 6, rate_limited: 7,
|
|
16
|
+
search_limit_reached: 7, temporary_service_failure: 7, billing_not_configured: 7,
|
|
17
|
+
authentication_not_configured: 7, cloud_storage_not_configured: 7,
|
|
18
|
+
subscription_required: 4, subscription_read_only: 4, payment_grace_expired: 4,
|
|
19
|
+
storage_limit_exceeded: 4, project_limit_reached: 4, member_limit_reached: 4,
|
|
20
|
+
file_too_large: 2 };
|
|
21
|
+
|
|
22
|
+
const CLOUD_HELP = `SmallDocs Cloud
|
|
23
|
+
|
|
24
|
+
Cloud stores selected Markdown documents with search, revisions, member access,
|
|
25
|
+
and cross-device CLI and browser access. Run \`sdoc setup --cloud --yes\` to make
|
|
26
|
+
ordinary \`sdoc FILE.md\` opens create or update the Cloud document first.
|
|
27
|
+
|
|
28
|
+
DISCOVER AND READ
|
|
29
|
+
|
|
30
|
+
sdoc cloud status --json
|
|
31
|
+
sdoc cloud tags --json
|
|
32
|
+
sdoc cloud search "incident response" --json
|
|
33
|
+
sdoc cloud search "authentication" --tag engineering --limit 10 --json
|
|
34
|
+
sdoc cloud ls --shared-with-me --json
|
|
35
|
+
sdoc cloud pull DOCUMENT_UUID --output /tmp/reference.md --no-bind --json
|
|
36
|
+
|
|
37
|
+
Search is case-insensitive substring matching across document titles,
|
|
38
|
+
filenames, tags, and current Markdown. It is not semantic search. Start with a
|
|
39
|
+
specific phrase, then try a shorter phrase or an existing tag if needed.
|
|
40
|
+
Multiple --tag values require every listed tag.
|
|
41
|
+
|
|
42
|
+
With --json, search returns documents[]. Each result includes its id, title,
|
|
43
|
+
tags, current revision metadata, and matches[]. A match reports field, line,
|
|
44
|
+
and snippet. Search does not return the full Markdown. Use pull to retrieve a
|
|
45
|
+
result. --no-bind makes that output a read-only reference from the CLI's point
|
|
46
|
+
of view, so a later push will not update the Cloud document by accident.
|
|
47
|
+
|
|
48
|
+
UPDATE AN EXISTING DOCUMENT
|
|
49
|
+
|
|
50
|
+
sdoc cloud pull DOCUMENT_UUID --output ./plan.md --json
|
|
51
|
+
# Edit ./plan.md with normal file tools.
|
|
52
|
+
sdoc cloud push ./plan.md --json
|
|
53
|
+
|
|
54
|
+
A normal pull binds the local path to the Cloud document and revision. Push
|
|
55
|
+
uses that binding. Inspect merge_classification, combined,
|
|
56
|
+
local_updated_from_cloud, and local_changed_after_upload in the JSON response.
|
|
57
|
+
Cloud may combine work saved by another writer since the pull.
|
|
58
|
+
|
|
59
|
+
CREATE, ORGANIZE, AND SHARE ACCESS
|
|
60
|
+
|
|
61
|
+
sdoc cloud create PATH [--account UUID] --json
|
|
62
|
+
sdoc cloud tag DOCUMENT_UUID --tag TAG [--tag TAG ...] --json
|
|
63
|
+
sdoc cloud access DOCUMENT_UUID [--only-you | --everyone | --member USER_UUID ...] --json
|
|
64
|
+
sdoc cloud members [--account UUID] --json
|
|
65
|
+
sdoc cloud permission-groups [--account UUID] --json
|
|
66
|
+
sdoc cloud notify DOCUMENT_UUID [--document DOCUMENT_UUID ...] --member USER_UUID ... [--note TEXT] --json
|
|
67
|
+
|
|
68
|
+
Notification sends a message to existing account members. It does not grant
|
|
69
|
+
access or create a user. List members and set access deliberately before
|
|
70
|
+
notifying them.
|
|
71
|
+
|
|
72
|
+
HISTORY AND DELETION
|
|
73
|
+
|
|
74
|
+
sdoc cloud history DOCUMENT_UUID --json
|
|
75
|
+
sdoc cloud restore DOCUMENT_UUID --revision REVISION_UUID --json
|
|
76
|
+
sdoc cloud delete DOCUMENT_UUID --base-revision UUID --json
|
|
77
|
+
sdoc cloud deleted --json
|
|
78
|
+
sdoc cloud undelete DOCUMENT_UUID --base-revision UUID --json
|
|
79
|
+
|
|
80
|
+
CONNECTION
|
|
81
|
+
|
|
82
|
+
sdoc cloud Show capabilities and the next setup step
|
|
83
|
+
sdoc cloud login [--no-open]
|
|
84
|
+
sdoc cloud logout
|
|
85
|
+
sdoc cloud status [--account UUID]
|
|
86
|
+
|
|
87
|
+
Use --account UUID with account-scoped discovery, creation, listing, or search
|
|
88
|
+
when status reports more than one account. Add --json to any command for one
|
|
89
|
+
machine-readable JSON object on stdout.`;
|
|
90
|
+
|
|
91
|
+
const CLOUD_OVERVIEW = `SmallDocs Cloud
|
|
92
|
+
|
|
93
|
+
SmallDocs Cloud is an optional paid feature for documents you choose to add.
|
|
94
|
+
|
|
95
|
+
- Open selected documents across browsers, mobile devices, and the CLI.
|
|
96
|
+
- Search document text and tags.
|
|
97
|
+
- Keep and restore revision history.
|
|
98
|
+
- Control access for account members and permission groups.
|
|
99
|
+
- Notify existing account members about one or more documents.
|
|
100
|
+
|
|
101
|
+
Local files remain local until you add them to Cloud. Encrypted snapshot links
|
|
102
|
+
created by sdoc share are separate from Cloud documents.
|
|
103
|
+
|
|
104
|
+
Run sdoc cloud --help for search, read, update, access, and history examples.`;
|
|
105
|
+
|
|
106
|
+
class CloudCommandError extends Error {
|
|
107
|
+
constructor(code, message, detail) {
|
|
108
|
+
super(message || code);
|
|
109
|
+
this.code = code;
|
|
110
|
+
if (detail) this.data = detail;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function origin() {
|
|
115
|
+
return String(process.env.SDOCS_CLOUD_URL || constants.DEFAULT_URL).replace(/\/$/, '');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function entitlementFailure(code, status, cloudOrigin) {
|
|
119
|
+
if (code === 'read_only') code = 'subscription_read_only';
|
|
120
|
+
if (status !== 402 && !['subscription_required', 'subscription_read_only',
|
|
121
|
+
'payment_grace_expired'].includes(code)) return null;
|
|
122
|
+
if (!['subscription_required', 'subscription_read_only', 'payment_grace_expired'].includes(code)) {
|
|
123
|
+
code = 'subscription_required';
|
|
124
|
+
}
|
|
125
|
+
if (code === 'subscription_required') {
|
|
126
|
+
return { code, message: 'An active SmallDocs Cloud subscription is required to change documents.',
|
|
127
|
+
action: 'subscribe', billing_url: cloudOrigin + '/cloud#pricing' };
|
|
128
|
+
}
|
|
129
|
+
return { code, message: 'This Cloud account is read-only because its subscription is not active. Ask an account owner to update billing.',
|
|
130
|
+
action: 'manage_billing', billing_url: cloudOrigin + '/cloud/admin' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class CloudClient {
|
|
134
|
+
constructor(options) {
|
|
135
|
+
options = options || {};
|
|
136
|
+
this.origin = options.origin || origin();
|
|
137
|
+
this.credentials = options.credentials || credentialStore;
|
|
138
|
+
this.fetch = options.fetch || global.fetch;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
loadCredential() { return this.credentials.load(this.origin); }
|
|
142
|
+
saveCredential(value) { this.credentials.save(this.origin, value); }
|
|
143
|
+
|
|
144
|
+
async raw(endpoint, options) {
|
|
145
|
+
let response;
|
|
146
|
+
try { response = await this.fetch(this.origin + endpoint, options || {}); }
|
|
147
|
+
catch (error) {
|
|
148
|
+
throw new CloudCommandError('temporary_service_failure',
|
|
149
|
+
'Could not reach SmallDocs Cloud.', { cause: error && error.message });
|
|
150
|
+
}
|
|
151
|
+
const data = await response.json().catch(() => null);
|
|
152
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
153
|
+
throw new CloudCommandError('temporary_service_failure',
|
|
154
|
+
'SmallDocs Cloud returned an invalid response.', { http_status: response.status });
|
|
155
|
+
}
|
|
156
|
+
return { response, data };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async refresh(credential) {
|
|
160
|
+
const result = await this.raw('/api/cloud/v1/cli/token/refresh', {
|
|
161
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
162
|
+
body: JSON.stringify({ refresh_token: credential.refresh_token }),
|
|
163
|
+
});
|
|
164
|
+
if (!result.response.ok) {
|
|
165
|
+
this.credentials.remove(this.origin);
|
|
166
|
+
throw new CloudCommandError('login_required', 'Cloud login has expired or was revoked.');
|
|
167
|
+
}
|
|
168
|
+
const next = { credential_id: result.data.credential_id, user_id: result.data.user_id,
|
|
169
|
+
access_token: result.data.access_token, access_token_expires_at: result.data.access_token_expires_at,
|
|
170
|
+
refresh_token: result.data.refresh_token };
|
|
171
|
+
this.saveCredential(next);
|
|
172
|
+
return next;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async authenticated(endpoint, options, retry) {
|
|
176
|
+
let credential = this.loadCredential();
|
|
177
|
+
if (!credential) throw new CloudCommandError('login_required', 'Run `sdoc cloud login`.');
|
|
178
|
+
if (Date.parse(credential.access_token_expires_at || 0) <= Date.now() + 30000) {
|
|
179
|
+
credential = await this.refresh(credential);
|
|
180
|
+
}
|
|
181
|
+
const headers = Object.assign({}, options && options.headers,
|
|
182
|
+
{ Authorization: 'Bearer ' + credential.access_token });
|
|
183
|
+
const result = await this.raw(endpoint, Object.assign({}, options, { headers }));
|
|
184
|
+
if (result.response.status === 401 && retry !== false) {
|
|
185
|
+
credential = await this.refresh(credential);
|
|
186
|
+
return this.authenticated(endpoint, options, false);
|
|
187
|
+
}
|
|
188
|
+
if (!result.response.ok) {
|
|
189
|
+
const entitlement = entitlementFailure(result.data.error, result.response.status, this.origin);
|
|
190
|
+
if (entitlement) {
|
|
191
|
+
throw new CloudCommandError(entitlement.code, entitlement.message,
|
|
192
|
+
Object.assign({}, result.data, { http_status: result.response.status,
|
|
193
|
+
action: entitlement.action, billing_url: entitlement.billing_url }));
|
|
194
|
+
}
|
|
195
|
+
var message = result.data.message;
|
|
196
|
+
if (result.data.error === 'account_selection_required') {
|
|
197
|
+
var choices = (result.data.accounts || []).map(function (account) {
|
|
198
|
+
return account.name + ' (' + account.id + ')';
|
|
199
|
+
}).join(', ');
|
|
200
|
+
message = 'Choose an account with --account.' + (choices ? ' Available: ' + choices : '');
|
|
201
|
+
} else if (result.data.error === 'account_required') {
|
|
202
|
+
message = 'Set up SmallDocs Cloud before using this command.';
|
|
203
|
+
}
|
|
204
|
+
throw new CloudCommandError(result.data.error || 'temporary_service_failure',
|
|
205
|
+
message, Object.assign({}, result.data, { http_status: result.response.status }));
|
|
206
|
+
}
|
|
207
|
+
return result.data;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function emit(opts, command, value, human) {
|
|
212
|
+
if (opts.jsonFlag) process.stdout.write(JSON.stringify(Object.assign({ ok: true, command }, value)) + '\n');
|
|
213
|
+
else process.stdout.write((human || JSON.stringify(value, null, 2)) + '\n');
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function skillInstallCommand(originValue, cloud) {
|
|
218
|
+
const edition = cloud ? 'cloud' : 'standard';
|
|
219
|
+
return 'npx skills@latest add ' + originValue + '/agent-skills/' + edition + ' --global';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function fail(opts, command, error) {
|
|
223
|
+
const code = error.code || 'unexpected';
|
|
224
|
+
const body = Object.assign({}, error.data || {},
|
|
225
|
+
{ ok: false, command, error: code, message: error.message || code });
|
|
226
|
+
if (opts.jsonFlag) process.stdout.write(JSON.stringify(body) + '\n');
|
|
227
|
+
else {
|
|
228
|
+
process.stderr.write('sdoc cloud: ' + body.message + '\n');
|
|
229
|
+
if (body.billing_url) process.stderr.write(
|
|
230
|
+
(body.action === 'subscribe' ? 'Subscribe: ' : 'Manage billing: ') + body.billing_url + '\n');
|
|
231
|
+
}
|
|
232
|
+
process.exitCode = EXIT[code] || 1;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
236
|
+
|
|
237
|
+
async function login(opts, client) {
|
|
238
|
+
const installCommand = skillInstallCommand(client.origin, true);
|
|
239
|
+
const setupCommand = 'sdoc setup --cloud --yes';
|
|
240
|
+
if (client.loadCredential()) {
|
|
241
|
+
try {
|
|
242
|
+
const me = await client.authenticated('/api/cloud/v1/me');
|
|
243
|
+
return emit(opts, 'cloud.login', { user: me.user, already_logged_in: true,
|
|
244
|
+
skill_mode: 'cloud', skill_install_command: installCommand,
|
|
245
|
+
cloud_first_setup_command: setupCommand },
|
|
246
|
+
'Already signed in as ' + (me.user.email || me.user.id) + '.\n' +
|
|
247
|
+
'Enable Cloud-first SmallDocs on this machine:\n' + setupCommand);
|
|
248
|
+
} catch (_) {}
|
|
249
|
+
}
|
|
250
|
+
const issued = await client.raw('/api/cloud/v1/cli/device-authorizations', {
|
|
251
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
252
|
+
body: JSON.stringify({ display_name: os.hostname() || 'SmallDocs CLI' }),
|
|
253
|
+
});
|
|
254
|
+
if (!issued.response.ok) throw new CloudCommandError(issued.data.error || 'temporary_service_failure');
|
|
255
|
+
process.stderr.write('Authorize this CLI at:\n' + issued.data.verification_uri_complete + '\nCode: ' + issued.data.user_code + '\n');
|
|
256
|
+
if (!opts.noOpenFlag) io.openBrowser(issued.data.verification_uri_complete,
|
|
257
|
+
(message) => process.stderr.write(message + '\n'));
|
|
258
|
+
const deadline = Date.now() + issued.data.expires_in * 1000;
|
|
259
|
+
while (Date.now() < deadline) {
|
|
260
|
+
await sleep(Math.max(1, issued.data.interval || 2) * 1000);
|
|
261
|
+
const polled = await client.raw('/api/cloud/v1/cli/device-authorizations/token', {
|
|
262
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
263
|
+
body: JSON.stringify({ device_code: issued.data.device_code }),
|
|
264
|
+
});
|
|
265
|
+
if (polled.response.status === 428 && polled.data.error === 'authorization_pending') continue;
|
|
266
|
+
if (!polled.response.ok) throw new CloudCommandError('login_required', polled.data.error);
|
|
267
|
+
const credential = { credential_id: polled.data.credential_id, user_id: polled.data.user_id,
|
|
268
|
+
access_token: polled.data.access_token, access_token_expires_at: polled.data.access_token_expires_at,
|
|
269
|
+
refresh_token: polled.data.refresh_token };
|
|
270
|
+
client.saveCredential(credential);
|
|
271
|
+
return emit(opts, 'cloud.login', { user_id: credential.user_id,
|
|
272
|
+
credential_id: credential.credential_id, skill_mode: 'cloud',
|
|
273
|
+
skill_install_command: installCommand,
|
|
274
|
+
cloud_first_setup_command: setupCommand },
|
|
275
|
+
'Cloud login saved for this machine.\n' +
|
|
276
|
+
'Enable Cloud-first SmallDocs on this machine:\n' + setupCommand);
|
|
277
|
+
}
|
|
278
|
+
throw new CloudCommandError('login_required', 'Authorization expired before it was approved.');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function logout(opts, client) {
|
|
282
|
+
const credential = client.loadCredential();
|
|
283
|
+
if (credential) {
|
|
284
|
+
try { await client.authenticated('/api/cloud/v1/cli/credentials/' + encodeURIComponent(credential.credential_id),
|
|
285
|
+
{ method: 'DELETE' }); } catch (_) {}
|
|
286
|
+
client.credentials.remove(client.origin);
|
|
287
|
+
}
|
|
288
|
+
const restoreCommand = skillInstallCommand(client.origin, false);
|
|
289
|
+
const standardSetupCommand = 'sdoc setup --standard --yes';
|
|
290
|
+
emit(opts, 'cloud.logout', { logged_out: true, skill_unchanged: true,
|
|
291
|
+
standard_skill_install_command: restoreCommand,
|
|
292
|
+
standard_setup_command: standardSetupCommand },
|
|
293
|
+
'Signed out of SmallDocs Cloud. The installed skill was not changed.\n' +
|
|
294
|
+
'If you do not expect to use Cloud on this machine, restore local-first behavior:\n' +
|
|
295
|
+
standardSetupCommand);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function filterTags(documents, tags) {
|
|
299
|
+
const wanted = (tags || []).map((tag) => String(tag).toLowerCase());
|
|
300
|
+
return documents.filter((document) => wanted.every((tag) => (document.tags || []).includes(tag)));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function requestedLimit(value, fallback) {
|
|
304
|
+
if (value == null) return fallback;
|
|
305
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
306
|
+
throw new CloudCommandError('invalid_request', '--limit must be a positive integer');
|
|
307
|
+
}
|
|
308
|
+
return value;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function list(opts, client) {
|
|
312
|
+
const target = requestedLimit(opts.limitFlag, 50);
|
|
313
|
+
const documents = [];
|
|
314
|
+
const seenCursors = new Set();
|
|
315
|
+
let cursor = null;
|
|
316
|
+
do {
|
|
317
|
+
const params = new URLSearchParams();
|
|
318
|
+
params.set('limit', String(Math.min(100, target - documents.length)));
|
|
319
|
+
if (opts.accountFlag) params.set('workspace_id', opts.accountFlag);
|
|
320
|
+
if (opts.sharedWithMeFlag) params.set('shared_with_me', '1');
|
|
321
|
+
if (cursor) params.set('cursor', cursor);
|
|
322
|
+
const response = await client.authenticated('/api/cloud/v1/documents?' + params.toString());
|
|
323
|
+
documents.push(...filterTags(response.documents || [], opts.tagFilters)
|
|
324
|
+
.slice(0, target - documents.length));
|
|
325
|
+
cursor = response.next_cursor || null;
|
|
326
|
+
if (cursor && seenCursors.has(cursor)) throw new CloudCommandError('temporary_service_failure',
|
|
327
|
+
'Cloud returned the same document cursor twice.');
|
|
328
|
+
if (cursor) seenCursors.add(cursor);
|
|
329
|
+
} while (documents.length < target && cursor);
|
|
330
|
+
emit(opts, 'cloud.ls', { documents, next_cursor: cursor }, documents.map((document) =>
|
|
331
|
+
document.id + ' ' + document.title + ' [' + (document.tags || []).join(', ') + ']').join('\n') || 'No Cloud documents.');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function notify(opts, client) {
|
|
335
|
+
const documentIds = Array.from(new Set([opts.extra].concat(opts.documentFlags || []).filter(Boolean)));
|
|
336
|
+
const recipientUserIds = Array.from(new Set(opts.memberFlags || []));
|
|
337
|
+
if (!documentIds.length || !recipientUserIds.length) {
|
|
338
|
+
throw new CloudCommandError('invalid_request',
|
|
339
|
+
'usage: sdoc cloud notify DOCUMENT_UUID [--document DOCUMENT_UUID ...] ' +
|
|
340
|
+
'--member USER_UUID ... [--note TEXT]');
|
|
341
|
+
}
|
|
342
|
+
const response = await client.authenticated('/api/cloud/v1/notifications', {
|
|
343
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
344
|
+
body: JSON.stringify({
|
|
345
|
+
document_ids: documentIds,
|
|
346
|
+
recipient_user_ids: recipientUserIds,
|
|
347
|
+
note: opts.noteText || undefined,
|
|
348
|
+
idempotency_key: crypto.randomUUID(),
|
|
349
|
+
}),
|
|
350
|
+
});
|
|
351
|
+
const notification = response.notification;
|
|
352
|
+
emit(opts, 'cloud.notify', { notification_id: notification.id,
|
|
353
|
+
document_ids: notification.document_ids,
|
|
354
|
+
recipient_user_ids: notification.recipient_user_ids },
|
|
355
|
+
notification.recipient_user_ids.length === 1
|
|
356
|
+
? 'Queued 1 notification email.'
|
|
357
|
+
: 'Queued ' + notification.recipient_user_ids.length +
|
|
358
|
+
' notification emails, one for each recipient.');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function tags(opts, client) {
|
|
362
|
+
const query = opts.accountFlag ? '?account_id=' + encodeURIComponent(opts.accountFlag) : '';
|
|
363
|
+
const response = await client.authenticated('/api/cloud/v1/account/tags' + query);
|
|
364
|
+
const values = (response.tags || []).map((item) => ({ tag: item.tag, document_count: item.count }));
|
|
365
|
+
emit(opts, 'cloud.tags', { tags: values }, values.map((item) => item.tag + ' - ' + item.document_count).join('\n') || 'No Cloud tags.');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function members(opts, client) {
|
|
369
|
+
const query = opts.accountFlag ? '?account_id=' + encodeURIComponent(opts.accountFlag) : '';
|
|
370
|
+
const response = await client.authenticated('/api/cloud/v1/account/members' + query);
|
|
371
|
+
const values = response.members || [];
|
|
372
|
+
emit(opts, 'cloud.members', { account_id: response.account_id, members: values }, values.map((member) =>
|
|
373
|
+
member.user_id + ' ' + (member.email || member.name) + (member.is_you ? ' You' : '')).join('\n') || 'No account members.');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function permissionGroups(opts, client) {
|
|
377
|
+
const query = opts.accountFlag ? '?account_id=' + encodeURIComponent(opts.accountFlag) : '';
|
|
378
|
+
const response = await client.authenticated('/api/cloud/v1/account/permission-groups' + query);
|
|
379
|
+
const values = response.permission_groups || [];
|
|
380
|
+
emit(opts, 'cloud.permission-groups', { account_id: response.account_id,
|
|
381
|
+
permission_groups: values }, values.map((group) => group.document_id + ' '
|
|
382
|
+
+ (group.mode === 'everyone' ? 'Everyone' : group.member_user_ids.join(', '))).join('\n') ||
|
|
383
|
+
'No Cloud permission groups.');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function access(opts, client) {
|
|
387
|
+
const documentId = opts.extra;
|
|
388
|
+
if (!documentId) throw new CloudCommandError('invalid_request',
|
|
389
|
+
'usage: sdoc cloud access DOCUMENT_UUID [--only-you | --everyone | --member USER_UUID ...]');
|
|
390
|
+
const choices = Number(Boolean(opts.onlyYouFlag)) + Number(Boolean(opts.everyoneFlag))
|
|
391
|
+
+ Number(Boolean(opts.memberFlags && opts.memberFlags.length));
|
|
392
|
+
if (choices !== 1) throw new CloudCommandError('invalid_request',
|
|
393
|
+
'choose one of --only-you, --everyone, or one or more --member values');
|
|
394
|
+
const mode = opts.everyoneFlag ? 'everyone' : 'custom';
|
|
395
|
+
const response = await client.authenticated('/api/cloud/v1/documents/' + encodeURIComponent(documentId)
|
|
396
|
+
+ '/permission', { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
397
|
+
body: JSON.stringify({ mode, member_user_ids: opts.memberFlags || [] }) });
|
|
398
|
+
emit(opts, 'cloud.access', { document_id: documentId, permission: response.permission },
|
|
399
|
+
'Updated access for ' + documentId + '.');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function setTags(opts, client) {
|
|
403
|
+
const documentId = opts.extra;
|
|
404
|
+
if (!documentId) throw new CloudCommandError('invalid_request',
|
|
405
|
+
'usage: sdoc cloud tag DOCUMENT_UUID --tag TAG [--tag TAG ...]');
|
|
406
|
+
const current = await client.authenticated('/api/cloud/v1/documents/' + encodeURIComponent(documentId));
|
|
407
|
+
const response = await client.authenticated('/api/cloud/v1/documents/' + encodeURIComponent(documentId)
|
|
408
|
+
+ '/tags', { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
409
|
+
body: JSON.stringify({ tags: opts.tagFilters || [],
|
|
410
|
+
expected_head_revision_id: current.document.current_revision_id,
|
|
411
|
+
idempotency_key: crypto.randomUUID() }) });
|
|
412
|
+
emit(opts, 'cloud.tag', { document_id: documentId,
|
|
413
|
+
revision_id: response.document.current_revision_id, tags: response.document.tags },
|
|
414
|
+
'Updated tags for ' + documentId + '.');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function search(opts, client) {
|
|
418
|
+
if (!opts.extra) throw new CloudCommandError('invalid_request', 'usage: sdoc cloud search QUERY');
|
|
419
|
+
const limit = requestedLimit(opts.limitFlag, 50);
|
|
420
|
+
const response = await client.authenticated('/api/cloud/v1/search', {
|
|
421
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
422
|
+
body: JSON.stringify({ query: opts.extra, tags: opts.tagFilters, limit,
|
|
423
|
+
workspace_id: opts.accountFlag || undefined }),
|
|
424
|
+
});
|
|
425
|
+
const documents = response.documents || [];
|
|
426
|
+
emit(opts, 'cloud.search', { documents, next_cursor: null }, documents.map((document) =>
|
|
427
|
+
document.id + ' ' + document.title + '\n ' + ((document.matches && document.matches[0] && document.matches[0].snippet) || '')).join('\n') || 'No matches.');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function requireFile(file) {
|
|
431
|
+
if (!file) throw new CloudCommandError('invalid_request', 'a Markdown file path is required');
|
|
432
|
+
const absolute = bindings.canonical(file);
|
|
433
|
+
if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
|
|
434
|
+
throw new CloudCommandError('invalid_request', 'file not found: ' + file);
|
|
435
|
+
}
|
|
436
|
+
return absolute;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function requireCredential(client) {
|
|
440
|
+
const credential = client.loadCredential();
|
|
441
|
+
if (!credential) throw new CloudCommandError('login_required', 'Run `sdoc cloud login`.');
|
|
442
|
+
return credential;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function create(opts, client) {
|
|
446
|
+
const file = requireFile(opts.extra);
|
|
447
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
448
|
+
const digest = bindings.hash(content);
|
|
449
|
+
const credential = requireCredential(client);
|
|
450
|
+
let pending = bindings.getPending(credential.user_id, file);
|
|
451
|
+
const destination = opts.accountFlag || 'default-account';
|
|
452
|
+
if (!pending || pending.operation !== 'create' || pending.destination !== destination || pending.sha256 !== digest) {
|
|
453
|
+
pending = { operation: 'create', destination, sha256: digest,
|
|
454
|
+
idempotency_key: crypto.randomUUID() };
|
|
455
|
+
bindings.setPending(credential.user_id, file, pending);
|
|
456
|
+
}
|
|
457
|
+
const response = await client.authenticated('/api/cloud/v1/account/documents', {
|
|
458
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
459
|
+
body: JSON.stringify({ account_id: opts.accountFlag,
|
|
460
|
+
filename: path.basename(file), markdown: content,
|
|
461
|
+
idempotency_key: pending.idempotency_key }),
|
|
462
|
+
});
|
|
463
|
+
const document = response.document;
|
|
464
|
+
bindings.set(credential.user_id, file, { document_id: document.id,
|
|
465
|
+
revision_id: document.current_revision_id, content_sha256: digest, updated_at: document.updated_at });
|
|
466
|
+
bindings.cacheBase(credential.user_id, document.id, document.current_revision_id, content);
|
|
467
|
+
bindings.clearPending(credential.user_id, file);
|
|
468
|
+
const localChanged = bindings.hash(fs.readFileSync(file)) !== digest;
|
|
469
|
+
return emit(opts, 'cloud.create', { document_id: document.id, revision_id: document.current_revision_id,
|
|
470
|
+
revision_number: document.revision_number,
|
|
471
|
+
account_id: response.account && response.account.id || opts.accountFlag || null, path: file,
|
|
472
|
+
tags: document.tags, sha256: digest, binding_created: true,
|
|
473
|
+
local_changed_after_upload: localChanged }, 'Created ' + document.id + (localChanged ? '; the local file changed again during upload.' : '.'));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function atomicFileWrite(file, content) {
|
|
477
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
478
|
+
const temporary = file + '.sdocs-tmp-' + process.pid;
|
|
479
|
+
fs.writeFileSync(temporary, content);
|
|
480
|
+
fs.renameSync(temporary, file);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function pull(opts, client) {
|
|
484
|
+
const documentId = opts.extra;
|
|
485
|
+
if (!documentId || !opts.outputPath) throw new CloudCommandError('invalid_request',
|
|
486
|
+
'usage: sdoc cloud pull DOCUMENT_UUID --output PATH');
|
|
487
|
+
const output = bindings.canonical(opts.outputPath);
|
|
488
|
+
const credential = requireCredential(client);
|
|
489
|
+
const existingBinding = bindings.get(credential.user_id, output);
|
|
490
|
+
if (opts.noBindFlag && existingBinding) {
|
|
491
|
+
throw new CloudCommandError('unsafe_local_state',
|
|
492
|
+
'--no-bind requires an output path that is not already bound');
|
|
493
|
+
}
|
|
494
|
+
if (opts.revisionFlag && !opts.noBindFlag) {
|
|
495
|
+
throw new CloudCommandError('unsafe_local_state',
|
|
496
|
+
'pulling a historical revision requires --no-bind');
|
|
497
|
+
}
|
|
498
|
+
if (existingBinding && existingBinding.document_id !== documentId && !opts.forceFlag) {
|
|
499
|
+
throw new CloudCommandError('unsafe_local_state',
|
|
500
|
+
'output is bound to a different Cloud document; use --force to replace and rebind it');
|
|
501
|
+
}
|
|
502
|
+
if (fs.existsSync(output) && !existingBinding && !opts.forceFlag) {
|
|
503
|
+
throw new CloudCommandError('unsafe_local_state', 'output exists and is not bound; use --force to replace it');
|
|
504
|
+
}
|
|
505
|
+
if (fs.existsSync(output) && existingBinding && !opts.forceFlag) {
|
|
506
|
+
const currentHash = bindings.hash(fs.readFileSync(output));
|
|
507
|
+
if (currentHash !== existingBinding.content_sha256) {
|
|
508
|
+
throw new CloudCommandError('unsafe_local_state', 'the bound file has local changes; use --force to replace it');
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const endpoint = '/api/cloud/v1/documents/' + encodeURIComponent(documentId)
|
|
512
|
+
+ (opts.revisionFlag ? '/revisions/' + encodeURIComponent(opts.revisionFlag) : '');
|
|
513
|
+
const response = await client.authenticated(endpoint);
|
|
514
|
+
const document = response.document;
|
|
515
|
+
atomicFileWrite(output, document.markdown);
|
|
516
|
+
const digest = bindings.hash(document.markdown);
|
|
517
|
+
if (!opts.noBindFlag) {
|
|
518
|
+
bindings.set(credential.user_id, output, { document_id: document.id,
|
|
519
|
+
revision_id: document.current_revision_id, content_sha256: digest, updated_at: document.updated_at });
|
|
520
|
+
bindings.cacheBase(credential.user_id, document.id, document.current_revision_id, document.markdown);
|
|
521
|
+
}
|
|
522
|
+
emit(opts, 'cloud.pull', { document_id: document.id, revision_id: document.current_revision_id,
|
|
523
|
+
revision_number: document.revision_number, path: output, tags: document.tags,
|
|
524
|
+
sha256: digest, binding_created: !opts.noBindFlag }, 'Pulled ' + document.id + ' to ' + output + '.');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function push(opts, client) {
|
|
528
|
+
const file = requireFile(opts.extra);
|
|
529
|
+
const credential = requireCredential(client);
|
|
530
|
+
const hasExplicitBinding = opts.documentFlag || opts.baseRevisionFlag;
|
|
531
|
+
if (hasExplicitBinding && (!opts.documentFlag || !opts.baseRevisionFlag)) {
|
|
532
|
+
throw new CloudCommandError('unsafe_local_state',
|
|
533
|
+
'provide both --document and --base-revision');
|
|
534
|
+
}
|
|
535
|
+
let binding = hasExplicitBinding ? null : bindings.get(credential.user_id, file);
|
|
536
|
+
if (hasExplicitBinding) {
|
|
537
|
+
binding = { document_id: opts.documentFlag, revision_id: opts.baseRevisionFlag };
|
|
538
|
+
} else if (!binding) {
|
|
539
|
+
throw new CloudCommandError('unsafe_local_state',
|
|
540
|
+
'file is not bound; provide both --document and --base-revision');
|
|
541
|
+
}
|
|
542
|
+
const localContent = fs.readFileSync(file, 'utf8');
|
|
543
|
+
const digest = bindings.hash(localContent);
|
|
544
|
+
if (binding.content_sha256 === digest) {
|
|
545
|
+
return emit(opts, 'cloud.push', { document_id: binding.document_id,
|
|
546
|
+
base_revision_id: binding.revision_id, revision_id: binding.revision_id,
|
|
547
|
+
sha256: digest, no_change: true }, 'No changes to push.');
|
|
548
|
+
}
|
|
549
|
+
const targetMarkdown = bindings.readBase(credential.user_id, binding.document_id,
|
|
550
|
+
binding.revision_id);
|
|
551
|
+
let content = localContent;
|
|
552
|
+
if (opts.cloudFirst && targetMarkdown != null) {
|
|
553
|
+
const cloudMeta = SDocYaml.parseFrontMatter(targetMarkdown).meta || {};
|
|
554
|
+
const cloudTags = Array.isArray(cloudMeta.tags) ? cloudMeta.tags : [];
|
|
555
|
+
if (cloudTags.length) {
|
|
556
|
+
const parsed = SDocYaml.parseFrontMatter(localContent);
|
|
557
|
+
const meta = Object.assign({}, parsed.meta, { tags: cloudTags });
|
|
558
|
+
content = SDocYaml.serializeFrontMatter(meta) + '\n' + parsed.body;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const uploadDigest = bindings.hash(content);
|
|
562
|
+
let pending = bindings.getPending(credential.user_id, file);
|
|
563
|
+
if (!pending || pending.document_id !== binding.document_id || pending.base_revision_id !== binding.revision_id || pending.sha256 !== uploadDigest) {
|
|
564
|
+
pending = { document_id: binding.document_id, base_revision_id: binding.revision_id,
|
|
565
|
+
sha256: uploadDigest, idempotency_key: crypto.randomUUID() };
|
|
566
|
+
bindings.setPending(credential.user_id, file, pending);
|
|
567
|
+
}
|
|
568
|
+
const response = await client.authenticated('/api/cloud/v1/documents/' + encodeURIComponent(binding.document_id) + '/revisions', {
|
|
569
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
570
|
+
body: JSON.stringify({ target_revision_id: binding.revision_id,
|
|
571
|
+
target_markdown: targetMarkdown == null ? undefined : targetMarkdown,
|
|
572
|
+
filename: path.basename(file), markdown: content, idempotency_key: pending.idempotency_key }),
|
|
573
|
+
});
|
|
574
|
+
const document = response.document;
|
|
575
|
+
const localChanged = bindings.hash(fs.readFileSync(file)) !== digest;
|
|
576
|
+
const savedContent = typeof document.markdown === 'string' ? document.markdown : content;
|
|
577
|
+
let savedLocalContent = savedContent;
|
|
578
|
+
if (opts.cloudFirst) {
|
|
579
|
+
const saved = SDocYaml.parseFrontMatter(savedContent);
|
|
580
|
+
const local = SDocYaml.parseFrontMatter(localContent);
|
|
581
|
+
const meta = Object.assign({}, saved.meta);
|
|
582
|
+
if (Object.hasOwn(local.meta || {}, 'tags')) meta.tags = local.meta.tags;
|
|
583
|
+
else delete meta.tags;
|
|
584
|
+
savedLocalContent = Object.keys(meta).length
|
|
585
|
+
? SDocYaml.serializeFrontMatter(meta) + '\n' + saved.body : saved.body;
|
|
586
|
+
}
|
|
587
|
+
const savedDigest = bindings.hash(savedLocalContent);
|
|
588
|
+
const localUpdated = !localChanged && savedLocalContent !== localContent;
|
|
589
|
+
if (!localChanged) {
|
|
590
|
+
if (localUpdated) atomicFileWrite(file, savedLocalContent);
|
|
591
|
+
bindings.set(credential.user_id, file, { document_id: document.id,
|
|
592
|
+
revision_id: document.current_revision_id, content_sha256: savedDigest,
|
|
593
|
+
updated_at: document.updated_at });
|
|
594
|
+
}
|
|
595
|
+
bindings.cacheBase(credential.user_id, document.id, document.current_revision_id, savedContent);
|
|
596
|
+
bindings.clearPending(credential.user_id, file);
|
|
597
|
+
return emit(opts, 'cloud.push', { document_id: document.id, base_revision_id: binding.revision_id,
|
|
598
|
+
revision_id: document.current_revision_id, revision_number: document.revision_number,
|
|
599
|
+
tags: document.tags, sha256: savedDigest, no_change: false,
|
|
600
|
+
merge_classification: document.merge_classification || 'clean',
|
|
601
|
+
combined: Boolean(document.combined),
|
|
602
|
+
comment_id_remaps: document.comment_id_remaps || [],
|
|
603
|
+
local_updated_from_cloud: localUpdated,
|
|
604
|
+
local_changed_after_upload: localChanged }, 'Pushed revision ' + document.revision_number
|
|
605
|
+
+ (localChanged ? '; the local file changed again during upload.'
|
|
606
|
+
: localUpdated ? ' and updated the local file with Cloud changes.' : '.'));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function isMarkdownFile(file) {
|
|
610
|
+
return /\.(?:md|markdown|mdown|mkd)$/i.test(String(file || ''));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function addCloudTags(documentId, requestedTags, client, local) {
|
|
614
|
+
const additions = Array.from(new Set((requestedTags || [])
|
|
615
|
+
.map((tag) => String(tag).trim().toLowerCase()).filter(Boolean)));
|
|
616
|
+
if (!additions.length) return null;
|
|
617
|
+
const current = await client.authenticated('/api/cloud/v1/documents/'
|
|
618
|
+
+ encodeURIComponent(documentId));
|
|
619
|
+
const existing = current.document && Array.isArray(current.document.tags)
|
|
620
|
+
? current.document.tags : [];
|
|
621
|
+
const tags = Array.from(new Set(existing.concat(additions)));
|
|
622
|
+
if (tags.length === existing.length && tags.every((tag, index) => tag === existing[index])) {
|
|
623
|
+
return current.document;
|
|
624
|
+
}
|
|
625
|
+
const response = await client.authenticated('/api/cloud/v1/documents/'
|
|
626
|
+
+ encodeURIComponent(documentId) + '/tags', {
|
|
627
|
+
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
628
|
+
body: JSON.stringify({ tags,
|
|
629
|
+
expected_head_revision_id: current.document.current_revision_id,
|
|
630
|
+
idempotency_key: crypto.randomUUID() }),
|
|
631
|
+
});
|
|
632
|
+
if (local && typeof response.document.markdown === 'string') {
|
|
633
|
+
const content = fs.readFileSync(local.file, 'utf8');
|
|
634
|
+
bindings.set(local.credential.user_id, local.file, {
|
|
635
|
+
document_id: documentId,
|
|
636
|
+
revision_id: response.document.current_revision_id,
|
|
637
|
+
content_sha256: bindings.hash(content),
|
|
638
|
+
updated_at: response.document.updated_at,
|
|
639
|
+
});
|
|
640
|
+
bindings.cacheBase(local.credential.user_id, documentId,
|
|
641
|
+
response.document.current_revision_id, response.document.markdown);
|
|
642
|
+
}
|
|
643
|
+
process.stdout.write('Updated Cloud tags for ' + documentId + '.\n');
|
|
644
|
+
return response.document;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function autoSyncOpen(opts, dependencies) {
|
|
648
|
+
if (!opts.file || !isMarkdownFile(opts.file)) return { synced: false };
|
|
649
|
+
const client = dependencies && dependencies.client || new CloudClient();
|
|
650
|
+
const accountId = opts.accountFlag || dependencies && dependencies.accountId || null;
|
|
651
|
+
const file = requireFile(opts.file);
|
|
652
|
+
const credential = requireCredential(client);
|
|
653
|
+
const binding = bindings.get(credential.user_id, file);
|
|
654
|
+
const syncOpts = Object.assign({}, opts, {
|
|
655
|
+
extra: file,
|
|
656
|
+
accountFlag: accountId,
|
|
657
|
+
jsonFlag: false,
|
|
658
|
+
cloudFirst: true,
|
|
659
|
+
});
|
|
660
|
+
const result = binding ? await push(syncOpts, client) : await create(syncOpts, client);
|
|
661
|
+
const tagged = await addCloudTags(result.document_id, opts.addTags, client,
|
|
662
|
+
{ credential, file });
|
|
663
|
+
return { synced: true, documentId: result.document_id,
|
|
664
|
+
revisionId: tagged && tagged.current_revision_id || result.revision_id, created: !binding };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function history(opts, client) {
|
|
668
|
+
const documentId = opts.extra;
|
|
669
|
+
if (!documentId) throw new CloudCommandError('invalid_request',
|
|
670
|
+
'usage: sdoc cloud history DOCUMENT_UUID');
|
|
671
|
+
const revisions = [];
|
|
672
|
+
const seenCursors = new Set();
|
|
673
|
+
let cursor = null;
|
|
674
|
+
do {
|
|
675
|
+
const params = new URLSearchParams({ limit: '100' });
|
|
676
|
+
if (cursor) params.set('cursor', cursor);
|
|
677
|
+
const response = await client.authenticated('/api/cloud/v1/documents/'
|
|
678
|
+
+ encodeURIComponent(documentId) + '/revisions?' + params.toString());
|
|
679
|
+
revisions.push(...(response.revisions || []));
|
|
680
|
+
cursor = response.next_cursor || null;
|
|
681
|
+
if (cursor && seenCursors.has(cursor)) throw new CloudCommandError('temporary_service_failure',
|
|
682
|
+
'Cloud returned the same revision cursor twice.');
|
|
683
|
+
if (cursor) seenCursors.add(cursor);
|
|
684
|
+
} while (cursor);
|
|
685
|
+
emit(opts, 'cloud.history', { document_id: documentId, revisions,
|
|
686
|
+
next_cursor: null }, revisions.map((revision) =>
|
|
687
|
+
revision.revision_number + ' ' + revision.id + ' ' + revision.created_at).join('\n') || 'No revisions.');
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function restore(opts, client) {
|
|
691
|
+
const documentId = opts.extra;
|
|
692
|
+
const sourceRevisionId = opts.revisionFlag;
|
|
693
|
+
if (!documentId || !sourceRevisionId) throw new CloudCommandError('invalid_request',
|
|
694
|
+
'usage: sdoc cloud restore DOCUMENT_UUID --revision REVISION_UUID');
|
|
695
|
+
const currentResponse = await client.authenticated('/api/cloud/v1/documents/'
|
|
696
|
+
+ encodeURIComponent(documentId));
|
|
697
|
+
const current = currentResponse.document;
|
|
698
|
+
if (!current || !current.current_revision_id) {
|
|
699
|
+
throw new CloudCommandError('temporary_service_failure', 'Cloud did not return the current document revision.');
|
|
700
|
+
}
|
|
701
|
+
const credential = client.loadCredential();
|
|
702
|
+
const pendingResource = documentId + ':' + sourceRevisionId;
|
|
703
|
+
let pending = bindings.getOperationPending(credential.user_id, 'restore', pendingResource);
|
|
704
|
+
if (!pending) {
|
|
705
|
+
pending = { expected_head_revision_id: current.current_revision_id,
|
|
706
|
+
idempotency_key: crypto.randomUUID() };
|
|
707
|
+
bindings.setOperationPending(credential.user_id, 'restore', pendingResource, pending);
|
|
708
|
+
}
|
|
709
|
+
const baseRevisionId = pending.expected_head_revision_id;
|
|
710
|
+
let response;
|
|
711
|
+
try {
|
|
712
|
+
response = await client.authenticated('/api/cloud/v1/documents/'
|
|
713
|
+
+ encodeURIComponent(documentId) + '/revisions/' + encodeURIComponent(sourceRevisionId) + '/restore', {
|
|
714
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
715
|
+
body: JSON.stringify({ expected_head_revision_id: baseRevisionId,
|
|
716
|
+
idempotency_key: pending.idempotency_key }),
|
|
717
|
+
});
|
|
718
|
+
} catch (error) {
|
|
719
|
+
if (error && error.code === 'revision_conflict') {
|
|
720
|
+
bindings.clearOperationPending(credential.user_id, 'restore', pendingResource);
|
|
721
|
+
}
|
|
722
|
+
throw error;
|
|
723
|
+
}
|
|
724
|
+
bindings.clearOperationPending(credential.user_id, 'restore', pendingResource);
|
|
725
|
+
const document = response.document;
|
|
726
|
+
emit(opts, 'cloud.restore', { document_id: document.id,
|
|
727
|
+
base_revision_id: baseRevisionId,
|
|
728
|
+
restored_from_revision_id: document.restored_from_revision_id || sourceRevisionId,
|
|
729
|
+
revision_id: document.current_revision_id,
|
|
730
|
+
revision_number: document.revision_number,
|
|
731
|
+
tags: document.tags }, 'Restored revision ' + sourceRevisionId + ' as revision '
|
|
732
|
+
+ document.revision_number + '.');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function deleteDocument(opts, client) {
|
|
736
|
+
const documentId = opts.extra;
|
|
737
|
+
if (!documentId || !opts.baseRevisionFlag) throw new CloudCommandError('invalid_request',
|
|
738
|
+
'usage: sdoc cloud delete DOCUMENT_UUID --base-revision UUID');
|
|
739
|
+
const response = await client.authenticated('/api/cloud/v1/documents/' + encodeURIComponent(documentId), {
|
|
740
|
+
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
|
|
741
|
+
body: JSON.stringify({ expected_head_revision_id: opts.baseRevisionFlag }),
|
|
742
|
+
});
|
|
743
|
+
const document = response.document;
|
|
744
|
+
emit(opts, 'cloud.delete', { document_id: document.id,
|
|
745
|
+
base_revision_id: opts.baseRevisionFlag, deleted_at: document.deleted_at,
|
|
746
|
+
purge_after: document.purge_after }, 'Deleted ' + document.id + '.');
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async function deletedDocuments(opts, client) {
|
|
750
|
+
const response = await client.authenticated('/api/cloud/v1/documents/deleted');
|
|
751
|
+
const documents = response.documents || [];
|
|
752
|
+
emit(opts, 'cloud.deleted', { documents }, documents.map((document) =>
|
|
753
|
+
document.id + ' ' + document.title + ' restore before ' + document.purge_after).join('\n') ||
|
|
754
|
+
'No deleted Cloud documents.');
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function undeleteDocument(opts, client) {
|
|
758
|
+
const documentId = opts.extra;
|
|
759
|
+
if (!documentId || !opts.baseRevisionFlag) throw new CloudCommandError('invalid_request',
|
|
760
|
+
'usage: sdoc cloud undelete DOCUMENT_UUID --base-revision UUID');
|
|
761
|
+
const response = await client.authenticated('/api/cloud/v1/documents/' +
|
|
762
|
+
encodeURIComponent(documentId) + '/restore', {
|
|
763
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
764
|
+
body: JSON.stringify({ expected_head_revision_id: opts.baseRevisionFlag }),
|
|
765
|
+
});
|
|
766
|
+
const document = response.document;
|
|
767
|
+
emit(opts, 'cloud.undelete', { document_id: document.id,
|
|
768
|
+
revision_id: document.current_revision_id, revision_number: document.revision_number,
|
|
769
|
+
tags: document.tags }, 'Restored ' + document.id + '.');
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function status(opts, client) {
|
|
773
|
+
const query = opts.accountFlag ? '?account_id=' + encodeURIComponent(opts.accountFlag) : '';
|
|
774
|
+
const me = await client.authenticated('/api/cloud/v1/account' + query);
|
|
775
|
+
const credential = client.loadCredential();
|
|
776
|
+
emit(opts, 'cloud.status', { user: me.user, account: me.account, accounts: me.accounts,
|
|
777
|
+
credential_id: credential.credential_id, origin: client.origin },
|
|
778
|
+
'Signed in as ' + (me.user.email || me.user.id) + '. Account: ' + me.account.name + '.');
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function overview(opts, client) {
|
|
782
|
+
const connected = Boolean(client.loadCredential());
|
|
783
|
+
const nextAction = connected ? 'sdoc cloud status --json' : 'sdoc cloud login';
|
|
784
|
+
emit(opts, 'cloud.overview', {
|
|
785
|
+
cloud_available: true,
|
|
786
|
+
connected,
|
|
787
|
+
capabilities: ['cross_device_access', 'search', 'revision_history',
|
|
788
|
+
'member_permissions', 'notifications'],
|
|
789
|
+
next_action: nextAction,
|
|
790
|
+
}, CLOUD_OVERVIEW + '\n\nNext: ' + nextAction);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function runCloudCommand(opts, dependencies) {
|
|
794
|
+
const action = String(opts.file || 'overview').toLowerCase();
|
|
795
|
+
const command = 'cloud.' + action;
|
|
796
|
+
const client = dependencies && dependencies.client || new CloudClient();
|
|
797
|
+
try {
|
|
798
|
+
if (opts.helpFlag || action === 'help') return process.stdout.write(CLOUD_HELP + '\n');
|
|
799
|
+
if (action === 'overview') return overview(opts, client);
|
|
800
|
+
if (action === 'login') return await login(opts, client);
|
|
801
|
+
if (action === 'logout') return await logout(opts, client);
|
|
802
|
+
if (action === 'status') return await status(opts, client);
|
|
803
|
+
if (action === 'members') return await members(opts, client);
|
|
804
|
+
if (action === 'tags') return await tags(opts, client);
|
|
805
|
+
if (action === 'permission-groups') return await permissionGroups(opts, client);
|
|
806
|
+
if (action === 'access') return await access(opts, client);
|
|
807
|
+
if (action === 'notify') return await notify(opts, client);
|
|
808
|
+
if (action === 'tag') return await setTags(opts, client);
|
|
809
|
+
if (action === 'ls') return await list(opts, client);
|
|
810
|
+
if (action === 'search') return await search(opts, client);
|
|
811
|
+
if (action === 'create') return await create(opts, client);
|
|
812
|
+
if (action === 'pull') return await pull(opts, client);
|
|
813
|
+
if (action === 'push') return await push(opts, client);
|
|
814
|
+
if (action === 'history') return await history(opts, client);
|
|
815
|
+
if (action === 'restore') return await restore(opts, client);
|
|
816
|
+
if (action === 'delete') return await deleteDocument(opts, client);
|
|
817
|
+
if (action === 'deleted') return await deletedDocuments(opts, client);
|
|
818
|
+
if (action === 'undelete') return await undeleteDocument(opts, client);
|
|
819
|
+
throw new CloudCommandError('invalid_request', 'unknown Cloud command: ' + action);
|
|
820
|
+
} catch (error) {
|
|
821
|
+
fail(opts, command, error);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
module.exports = { CloudClient, CloudCommandError, runCloudCommand, autoSyncOpen, filterTags, origin,
|
|
826
|
+
EXIT, CLOUD_HELP, CLOUD_OVERVIEW, entitlementFailure, skillInstallCommand };
|