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