sdocs-dev 1.18.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 +16 -5
- package/lib/agent-block.js +25 -8
- package/lib/agent-files.js +8 -4
- package/lib/cloud-commands.js +108 -23
- package/lib/cloud-credentials.js +1 -1
- package/lib/commands.js +2 -1
- package/lib/help-text.js +29 -0
- package/lib/io.js +9 -2
- package/lib/setup.js +43 -8
- package/lib/short-link.js +38 -1
- package/package.json +1 -1
package/bin/sdocs-dev.js
CHANGED
|
@@ -64,7 +64,10 @@ function buildRouter() {
|
|
|
64
64
|
r.register('comments', { handler: () => { console.log(helpText.COMMENTS_HELP); process.exit(0); } });
|
|
65
65
|
|
|
66
66
|
// Setup / refresh / auto-update.
|
|
67
|
-
r.register('setup', { handler: async (opts) => { await setup.runSetup({
|
|
67
|
+
r.register('setup', { handler: async (opts) => { await setup.runSetup({
|
|
68
|
+
force: true, yes: !!opts.yesFlag, dryRun: !!opts.dryRunFlag,
|
|
69
|
+
edition: opts.skillEdition, accountId: opts.accountFlag,
|
|
70
|
+
}); process.exit(0); } });
|
|
68
71
|
r.register('refresh', { handler: async () => { await setup.runRefresh(); process.exit(0); } });
|
|
69
72
|
r.register('auto-update', { handler: (opts) => {
|
|
70
73
|
// Sub-arg lives in opts.file (positional). Accept on/off/empty.
|
|
@@ -116,10 +119,15 @@ function buildRouter() {
|
|
|
116
119
|
r.register('library', { handler: async (opts) => { await libraryCommands.libraryCommand(opts); /* libraryOpen blocks */ } });
|
|
117
120
|
r.register('cloud', { handler: async (opts) => { await cloudCommands.runCloudCommand(opts); } });
|
|
118
121
|
|
|
119
|
-
r.register(null, { handler: (opts) => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
r.register(null, { handler: async (opts) => {
|
|
123
|
+
const cloudFirst = setup.cloudFirstSettings();
|
|
124
|
+
if (cloudFirst) {
|
|
125
|
+
await cloudCommands.autoSyncOpen(opts, { accountId: cloudFirst.accountId });
|
|
126
|
+
} else {
|
|
127
|
+
// Index-on-open tap: fires before the open so any +tag CLI args land
|
|
128
|
+
// in the file's front matter before the browser receives the content.
|
|
129
|
+
libraryCommands.tapOpen(opts);
|
|
130
|
+
}
|
|
123
131
|
return commands.openCommand(opts);
|
|
124
132
|
} });
|
|
125
133
|
|
|
@@ -163,6 +171,9 @@ module.exports = {
|
|
|
163
171
|
compressAndEncrypt: shortLink.compressAndEncrypt,
|
|
164
172
|
uploadShortLink: shortLink.uploadShortLink,
|
|
165
173
|
buildShortUrl: shortLink.buildShortUrl,
|
|
174
|
+
normalizePlacementId: shortLink.normalizePlacementId,
|
|
175
|
+
buildAttributionQuery: shortLink.buildAttributionQuery,
|
|
176
|
+
assembleShortUrl: shortLink.assembleShortUrl,
|
|
166
177
|
|
|
167
178
|
// Default styles
|
|
168
179
|
mergeStyles: styles.mergeStyles,
|
package/lib/agent-block.js
CHANGED
|
@@ -28,16 +28,16 @@ const path = require('path');
|
|
|
28
28
|
const { SETUP_CACHE } = require('./constants');
|
|
29
29
|
|
|
30
30
|
// ── Skill model ────────────────────────────────────────────
|
|
31
|
-
const SKILL_VERSION =
|
|
32
|
-
const SKILL_REASON = '
|
|
31
|
+
const SKILL_VERSION = 29;
|
|
32
|
+
const SKILL_REASON = 'The Cloud edition now treats ordinary sdoc opens as automatic Cloud creates or updates and keeps tags in Cloud.';
|
|
33
33
|
const SKILL_NAME = 'smalldocs';
|
|
34
34
|
|
|
35
35
|
// Always-in-context preamble. Concise trigger text; the full reference lives
|
|
36
36
|
// in SKILL_BODY and loads on demand. Plain text: no backticks, no em/en dashes,
|
|
37
37
|
// no double quotes (it is emitted as a double-quoted YAML scalar).
|
|
38
|
-
const SKILL_DESCRIPTION = "Use SmallDocs when the user says sdoc, S-doc, smalldoc, sdoc this, or asks to open, present, share, style, save, or walk through a Markdown document with SmallDocs. Create or locate the Markdown file and use sdoc FILE.md for normal viewing. For a document walkthrough add source-line annotations to the sdoc command; the source file stays unchanged. For a presentation run sdoc slides, create a Markdown deck from the source material, run sdoc slides verify FILE.md --json and fix every error, then run sdoc present FILE.md.
|
|
38
|
+
const SKILL_DESCRIPTION = "Use SmallDocs when the user says sdoc, S-doc, smalldoc, sdoc this, or asks to open, present, share, style, save, or walk through a Markdown document with SmallDocs. Create or locate the Markdown file and use sdoc FILE.md for normal viewing. For a document walkthrough add source-line annotations to the sdoc command; the source file stays unchanged. For a presentation run sdoc slides, create a Markdown deck from the source material, run sdoc slides verify FILE.md --json and fix every error, then run sdoc present FILE.md. Use a runnable browser component only when the reader must interact with the result to understand something static document forms cannot express as clearly. When that criterion is met, run sdoc apps, use an sdoc-app block, and check inline, fullscreen, narrow, and wide layouts. Run the matching bare reference command before other specialised syntax. Local files stay local unless the user explicitly requests sharing or Cloud storage.";
|
|
39
39
|
|
|
40
|
-
const CLOUD_SKILL_DESCRIPTION = "Use SmallDocs when the user says sdoc, S-doc, smalldoc, sdoc this, or asks to open, present, share, style, search, save, or walk through a Markdown document with SmallDocs.
|
|
40
|
+
const CLOUD_SKILL_DESCRIPTION = "Use SmallDocs when the user says sdoc, S-doc, smalldoc, sdoc this, or asks to open, present, share, style, search, save, or walk through a Markdown document with SmallDocs. This user has enabled Cloud-first mode: use sdoc FILE.md for new and updated Markdown so the CLI automatically creates or updates the Cloud copy before opening it. Use +tag arguments for Cloud tags. For relevant prior Cloud material run sdoc cloud status --json, search, then pull. For a presentation run sdoc slides, create a Markdown deck, run sdoc slides verify FILE.md --json and fix every error, run sdoc FILE.md to store it, then run sdoc present FILE.md. Use a runnable browser component only when the reader must interact with the result to understand something static document forms cannot express as clearly. When that criterion is met, run sdoc apps and check inline, fullscreen, narrow, and wide layouts.";
|
|
41
41
|
|
|
42
42
|
const STANDARD_CLOUD_SKILL_SECTION = `### SmallDocs Cloud is available
|
|
43
43
|
|
|
@@ -72,6 +72,8 @@ ${STANDARD_CLOUD_SKILL_SECTION}
|
|
|
72
72
|
|
|
73
73
|
SmallDocs uses the browser to extend what Markdown can be: a styled doc, a chart, a diagram, a slide deck, or an interactive form whose answers come back to you. Reach for one of these when a visual or interactive artifact will land harder than prose - not as a default for every reply. To create something new, write the \`.md\` file first, then \`sdoc path/to/file.md\`.
|
|
74
74
|
|
|
75
|
+
Use runnable HTML only when the interaction carries meaning that prose, a Mermaid diagram, a chart, or computed cells cannot express as clearly. Choose it when the reader must manipulate inputs, simulate behavior, rotate or zoom a spatial model, or explore linked states to understand the result. A three-input financial model may warrant an interactive surface when the relationship between assumptions matters; exact values alone normally belong in cells or a table. Before authoring, state what the interaction helps the reader understand. If the reason describes only appearance or layout, use a static document form.
|
|
76
|
+
|
|
75
77
|
Each command below prints its reference when run with no arguments - run it before writing the matching fenced block. The JSON / DSL shapes are specific and easy to get wrong from memory.
|
|
76
78
|
|
|
77
79
|
For a presentation request, follow this sequence rather than treating it as an ordinary document:
|
|
@@ -83,7 +85,7 @@ For a presentation request, follow this sequence rather than treating it as an o
|
|
|
83
85
|
|
|
84
86
|
- \`sdoc charts\` - rendering inline charts (\`\`\`chart blocks)
|
|
85
87
|
- \`sdoc diagrams\` - rendering inline Mermaid diagrams (\`\`\`mermaid blocks; has full-screen mode for zoom). Reach for this when drawing system or architectural diagrams (sequence, flow, component layout) - a diagram often communicates the shape of something faster than the equivalent prose.
|
|
86
|
-
- \`sdoc apps\` - runnable HTML components (\`\`\`sdoc-app blocks): one complete HTML document with its own CSS, JavaScript, and data. Start with semantic HTML: SmallDocs supplies the document's current typography, colours, spacing, background, and control treatment in a low-priority CSS layer. Ordinary component CSS wins, and the \`--sdoc-app-*\` custom properties support targeted overrides. Let the tool's purpose determine its layout: prefer a clear page, list, table, or form before a dashboard of cards, use a canvas or stage for spatial interaction, and add surfaces, colour, and distinctive shapes when they encode structure or state. The component's document layout owns its inline height, while its width follows the reading column. Write responsive CSS for both the column and fullscreen viewport. It expands without losing state and joins Previous / Next navigation when the document contains several components. Use \`<title>\` to name it. Ordinary \`\`\`html remains source. Run \`sdoc apps\` before authoring and test every control inline, fullscreen, narrow, and wide.
|
|
88
|
+
- \`sdoc apps\` - runnable HTML components (\`\`\`sdoc-app blocks): one complete HTML document with its own CSS, JavaScript, and data. Use it when manipulation, simulation, spatial navigation, or linked states communicate something prose, diagrams, charts, or cells cannot express as clearly. If a static form communicates the result equally well, use it. Start with semantic HTML: SmallDocs supplies the document's current typography, colours, spacing, background, and control treatment in a low-priority CSS layer. Ordinary component CSS wins, and the \`--sdoc-app-*\` custom properties support targeted overrides. Let the tool's purpose determine its layout: prefer a clear page, list, table, or form before a dashboard of cards, use a canvas or stage for spatial interaction, and add surfaces, colour, and distinctive shapes when they encode structure or state. The component's document layout owns its inline height, while its width follows the reading column. Write responsive CSS for both the column and fullscreen viewport. It expands without losing state and joins Previous / Next navigation when the document contains several components. Use \`<title>\` to name it. Ordinary \`\`\`html remains source. Run \`sdoc apps\` before authoring and test every control inline, fullscreen, narrow, and wide.
|
|
87
89
|
- \`sdoc slides\` - inline slide decks (\`\`\`slide / ~~~slide blocks; has full-screen presentation mode). Slides can be standalone exported as \`.pdf\` or \`.pptx\`. Run \`sdoc slides verify file.md --json\` after authoring; fix every diagnostic, or add \`bleed=allow\` only to an individual shape whose off-canvas placement is intentional, then rerun until it exits 0. Use \`sdoc present file.md\` for the visual check that headless validation cannot perform.
|
|
88
90
|
- \`sdoc cells\` - rendering spreadsheets (\`\`\`cells blocks): CSV rows where plain values and =formulas (SUM, AVERAGE, IF, ROUND...) sit in the same grid and compute live. The reader can sort, select ranges for quick stats, edit a scratch copy fullscreen, and download the sheet as Excel (.xlsx) with the formulas still working. Name a block (\`\`\`cells Expenses) to build a workbook of several tabs whose formulas reference each other across sheets (\`=Expenses!B4\`); run \`sdoc cells verify file.md\` to compute the whole workbook headlessly and read the values back. Reach for this when handing the user numbers they will want to check or play with - totals, budgets, projections. \`sdoc report.csv\` opens a CSV file directly as a sheet.
|
|
89
91
|
- \`sdoc code\` - opening a source file or a fenced code block as a syntax-highlighted listing: a light code viewer for reading code with the user away from the IDE. \`sdoc app.rb\` (or \`.js\`, \`.py\`, \`.go\`, \`.rs\`, \`.ts\`...) opens a file as a highlighted listing; a \`\`\`lang fenced block is highlighted inline. Comments in the source get a prominent lane so the code reads clearly top to bottom. The fullscreen view adds a line-number gutter and language-aware folding (collapse a whole method or class); a comment mode lets the user annotate a line or method with review notes, kept in the browser rather than the file. You can also pin your own explanations to lines as you open a file - \`sdoc app.py 22:"the bug is here" 25-28:"wrong comparison"\` - and the file opens as a guided walkthrough: each note is a markdown callout below its line with a Prev / Next stepper, walked in the order you pass the notes (not line order). Name several files to narrate across them - \`sdoc app.py 5:"entry point" util.py 12:"it calls into here" app.py 9:"back here"\` - and each becomes a tab the walkthrough hops between. When the user asks you to walk them through code, an MR, a diff, or the current changes, build one of these. The file rides in the URL like any document; nothing is uploaded. Reach for it when reading or reviewing code with the user, not for prose.
|
|
@@ -93,7 +95,9 @@ For a presentation request, follow this sequence rather than treating it as an o
|
|
|
93
95
|
|
|
94
96
|
const CLOUD_SKILL_SECTION = `### SmallDocs Cloud for agents
|
|
95
97
|
|
|
96
|
-
This user has enabled
|
|
98
|
+
This user has enabled Cloud-first mode. Treat Cloud as the normal destination for SmallDocs Markdown. Write or update the local Markdown working copy, then run \`sdoc FILE.md\`. The CLI creates a Cloud document for an unbound file or pushes a revision for a bound file before it opens the browser. If the Cloud write fails, stop and report the error instead of presenting the local copy as saved.
|
|
99
|
+
|
|
100
|
+
Use \`sdoc FILE.md +tag1 +tag2\` to add Cloud tags. Do not add SmallDocs tags to local front matter and do not use the local library. Use \`sdoc cloud ls\`, \`sdoc cloud search\`, and \`sdoc cloud tags\` for discovery. Use \`sdoc share\` only when the user explicitly asks for an encrypted snapshot link rather than the managed Cloud document.
|
|
97
101
|
|
|
98
102
|
Treat Cloud as a source of context, not only a place to save new work. When earlier decisions, research, plans, or documentation could materially inform the task, search Cloud before recreating that context. Use specific project terms first and try shorter terms or existing tags when a search returns nothing. Do not search unrelated Cloud documents merely because Cloud is enabled.
|
|
99
103
|
|
|
@@ -114,11 +118,24 @@ When earlier Cloud material should inform new work, use this sequence:
|
|
|
114
118
|
- When updating a bound document, the local binding supplies the revision the agent edited. Cloud keeps separate changes from other writers; overlapping replacements may both remain. If the server combines content and the file did not change during upload, push writes the combined Markdown back to the local file. Inspect \`merge_classification\`, \`combined\`, and \`local_updated_from_cloud\` in the JSON result.
|
|
115
119
|
- Inspect or recover history with \`sdoc cloud history DOCUMENT_UUID\` and \`sdoc cloud restore DOCUMENT_UUID --revision REVISION_UUID\`.
|
|
116
120
|
|
|
117
|
-
Cloud documents are identified by UUID, not filename. An account is the
|
|
121
|
+
Cloud documents are identified by UUID, not filename. An account is the access boundary; tags organize documents inside it. The local file remains the editable working copy and its binding records the Cloud document and base revision.
|
|
118
122
|
|
|
119
123
|
`;
|
|
120
124
|
|
|
121
|
-
const CLOUD_SKILL_BODY = SKILL_BODY
|
|
125
|
+
const CLOUD_SKILL_BODY = SKILL_BODY
|
|
126
|
+
.replace(
|
|
127
|
+
'The `sdoc` CLI (`sdoc path/to/file.md`) is installed globally and renders local Markdown files securely in the browser (at https://smalldocs.org) in a way that\'s comfortable for your user to read and share. Nothing hits a server unless the user explicitly saves the file to the SmallDocs cloud or runs `sdoc share`.',
|
|
128
|
+
'The `sdoc` CLI (`sdoc path/to/file.md`) is installed globally and renders Markdown in the browser at https://smalldocs.org. Cloud-first mode uploads a new Markdown file or pushes a bound file before opening it, so the Cloud copy stays current while the local file remains the editable working copy.',
|
|
129
|
+
)
|
|
130
|
+
.replace(
|
|
131
|
+
'- `sdoc file.md` - the default way to open a file, for comfortable reading or quick sharing.',
|
|
132
|
+
'- `sdoc file.md` - create or update the Cloud document, then open it for reading.',
|
|
133
|
+
)
|
|
134
|
+
.replace(
|
|
135
|
+
'- `sdoc library` - opens a library view containing files previously opened with `sdoc path/to/file.md`; filter by directory, date, or tags (the index doesn\'t search file content - fall back to `grep` for that). Opt out per-file with `sdocs-library: false` in front matter. (`sdoc library --help` for the full reference.)\n- `sdoc library ls --tags` - list the current project\'s tags by frequency. When tags would make a document worth rediscovering, run this before choosing them. Prefer an existing tag that fits; introduce a new one when none does.\n- `sdoc file.md +tag1 +tag2` - open the file and add the selected tags to its YAML front matter. The `+` prefix is shell-safe and the tags persist.',
|
|
136
|
+
'- `sdoc cloud ls` - list documents in Cloud.\n- `sdoc cloud tags` - list Cloud tags by frequency. Prefer an existing tag that fits when the document should be easy to find again.\n- `sdoc file.md +tag1 +tag2` - create or update the Cloud document and add the selected Cloud tags. The local Markdown is not changed.',
|
|
137
|
+
)
|
|
138
|
+
.replace(STANDARD_CLOUD_SKILL_SECTION, CLOUD_SKILL_SECTION);
|
|
122
139
|
|
|
123
140
|
function formatSkill(version, options) {
|
|
124
141
|
const cloud = Boolean(options && options.cloud);
|
package/lib/agent-files.js
CHANGED
|
@@ -110,7 +110,7 @@ function pathsResolveSame(a, b) {
|
|
|
110
110
|
|
|
111
111
|
// ── canonical skill ────────────────────────────────────────
|
|
112
112
|
|
|
113
|
-
function refreshCanonicalSkill(home) {
|
|
113
|
+
function refreshCanonicalSkill(home, opts = {}) {
|
|
114
114
|
home = home || os.homedir();
|
|
115
115
|
const file = canonicalSkillFile(home);
|
|
116
116
|
let existing = null;
|
|
@@ -130,23 +130,27 @@ function refreshCanonicalSkill(home) {
|
|
|
130
130
|
}
|
|
131
131
|
const currentVersion = existing ? readSkillVersion(existing) : null;
|
|
132
132
|
const currentEdition = existing ? readSkillEdition(existing) : 'standard';
|
|
133
|
+
const requestedEdition = opts.edition === 'cloud' || opts.edition === 'standard'
|
|
134
|
+
? opts.edition : null;
|
|
135
|
+
const desiredEdition = requestedEdition || currentEdition;
|
|
133
136
|
if (existing !== null && currentVersion === null) {
|
|
134
137
|
return {
|
|
135
138
|
changed: false, reason: 'conflict', path: file,
|
|
136
139
|
error: 'existing canonical SKILL.md is not managed by SmallDocs; left untouched',
|
|
137
140
|
};
|
|
138
141
|
}
|
|
139
|
-
if (currentVersion === SKILL_VERSION) {
|
|
142
|
+
if (currentVersion === SKILL_VERSION && currentEdition === desiredEdition) {
|
|
140
143
|
return { changed: false, reason: 'current', path: file };
|
|
141
144
|
}
|
|
142
145
|
if (currentVersion !== null && currentVersion > SKILL_VERSION) {
|
|
143
146
|
return { changed: false, reason: 'newer', path: file };
|
|
144
147
|
}
|
|
145
148
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
146
|
-
atomicWrite(file, formatSkill(SKILL_VERSION, { cloud:
|
|
149
|
+
atomicWrite(file, formatSkill(SKILL_VERSION, { cloud: desiredEdition === 'cloud' }));
|
|
147
150
|
return {
|
|
148
151
|
changed: true, path: file,
|
|
149
152
|
fromVersion: currentVersion || 0, toVersion: SKILL_VERSION,
|
|
153
|
+
fromEdition: currentEdition, toEdition: desiredEdition,
|
|
150
154
|
};
|
|
151
155
|
}
|
|
152
156
|
|
|
@@ -298,7 +302,7 @@ function syncAgentSkill(opts = {}) {
|
|
|
298
302
|
const canonicalDir = canonicalSkillDir(home);
|
|
299
303
|
|
|
300
304
|
const result = {
|
|
301
|
-
canonical: refreshCanonicalSkill(home),
|
|
305
|
+
canonical: refreshCanonicalSkill(home, { edition: opts.edition }),
|
|
302
306
|
links: [],
|
|
303
307
|
stripped: [],
|
|
304
308
|
errors: [],
|
package/lib/cloud-commands.js
CHANGED
|
@@ -6,6 +6,7 @@ const constants = require('./constants');
|
|
|
6
6
|
const io = require('./io');
|
|
7
7
|
const credentialStore = require('./cloud-credentials');
|
|
8
8
|
const bindings = require('./cloud-bindings');
|
|
9
|
+
const SDocYaml = require('../shared/sdocs-yaml.js');
|
|
9
10
|
|
|
10
11
|
const EXIT = { unexpected: 1, invalid_request: 2, login_required: 3,
|
|
11
12
|
resource_unavailable: 4, account_required: 4, account_selection_required: 4,
|
|
@@ -21,8 +22,8 @@ const EXIT = { unexpected: 1, invalid_request: 2, login_required: 3,
|
|
|
21
22
|
const CLOUD_HELP = `SmallDocs Cloud
|
|
22
23
|
|
|
23
24
|
Cloud stores selected Markdown documents with search, revisions, member access,
|
|
24
|
-
and cross-device CLI and browser access.
|
|
25
|
-
create or
|
|
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.
|
|
26
27
|
|
|
27
28
|
DISCOVER AND READ
|
|
28
29
|
|
|
@@ -210,6 +211,7 @@ class CloudClient {
|
|
|
210
211
|
function emit(opts, command, value, human) {
|
|
211
212
|
if (opts.jsonFlag) process.stdout.write(JSON.stringify(Object.assign({ ok: true, command }, value)) + '\n');
|
|
212
213
|
else process.stdout.write((human || JSON.stringify(value, null, 2)) + '\n');
|
|
214
|
+
return value;
|
|
213
215
|
}
|
|
214
216
|
|
|
215
217
|
function skillInstallCommand(originValue, cloud) {
|
|
@@ -234,13 +236,15 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
|
234
236
|
|
|
235
237
|
async function login(opts, client) {
|
|
236
238
|
const installCommand = skillInstallCommand(client.origin, true);
|
|
239
|
+
const setupCommand = 'sdoc setup --cloud --yes';
|
|
237
240
|
if (client.loadCredential()) {
|
|
238
241
|
try {
|
|
239
242
|
const me = await client.authenticated('/api/cloud/v1/me');
|
|
240
243
|
return emit(opts, 'cloud.login', { user: me.user, already_logged_in: true,
|
|
241
|
-
skill_mode: 'cloud', skill_install_command: installCommand
|
|
244
|
+
skill_mode: 'cloud', skill_install_command: installCommand,
|
|
245
|
+
cloud_first_setup_command: setupCommand },
|
|
242
246
|
'Already signed in as ' + (me.user.email || me.user.id) + '.\n' +
|
|
243
|
-
'
|
|
247
|
+
'Enable Cloud-first SmallDocs on this machine:\n' + setupCommand);
|
|
244
248
|
} catch (_) {}
|
|
245
249
|
}
|
|
246
250
|
const issued = await client.raw('/api/cloud/v1/cli/device-authorizations', {
|
|
@@ -266,10 +270,10 @@ async function login(opts, client) {
|
|
|
266
270
|
client.saveCredential(credential);
|
|
267
271
|
return emit(opts, 'cloud.login', { user_id: credential.user_id,
|
|
268
272
|
credential_id: credential.credential_id, skill_mode: 'cloud',
|
|
269
|
-
skill_install_command: installCommand
|
|
273
|
+
skill_install_command: installCommand,
|
|
274
|
+
cloud_first_setup_command: setupCommand },
|
|
270
275
|
'Cloud login saved for this machine.\n' +
|
|
271
|
-
'
|
|
272
|
-
installCommand);
|
|
276
|
+
'Enable Cloud-first SmallDocs on this machine:\n' + setupCommand);
|
|
273
277
|
}
|
|
274
278
|
throw new CloudCommandError('login_required', 'Authorization expired before it was approved.');
|
|
275
279
|
}
|
|
@@ -282,11 +286,13 @@ async function logout(opts, client) {
|
|
|
282
286
|
client.credentials.remove(client.origin);
|
|
283
287
|
}
|
|
284
288
|
const restoreCommand = skillInstallCommand(client.origin, false);
|
|
289
|
+
const standardSetupCommand = 'sdoc setup --standard --yes';
|
|
285
290
|
emit(opts, 'cloud.logout', { logged_out: true, skill_unchanged: true,
|
|
286
|
-
standard_skill_install_command: restoreCommand
|
|
291
|
+
standard_skill_install_command: restoreCommand,
|
|
292
|
+
standard_setup_command: standardSetupCommand },
|
|
287
293
|
'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
|
|
289
|
-
|
|
294
|
+
'If you do not expect to use Cloud on this machine, restore local-first behavior:\n' +
|
|
295
|
+
standardSetupCommand);
|
|
290
296
|
}
|
|
291
297
|
|
|
292
298
|
function filterTags(documents, tags) {
|
|
@@ -460,7 +466,7 @@ async function create(opts, client) {
|
|
|
460
466
|
bindings.cacheBase(credential.user_id, document.id, document.current_revision_id, content);
|
|
461
467
|
bindings.clearPending(credential.user_id, file);
|
|
462
468
|
const localChanged = bindings.hash(fs.readFileSync(file)) !== digest;
|
|
463
|
-
emit(opts, 'cloud.create', { document_id: document.id, revision_id: document.current_revision_id,
|
|
469
|
+
return emit(opts, 'cloud.create', { document_id: document.id, revision_id: document.current_revision_id,
|
|
464
470
|
revision_number: document.revision_number,
|
|
465
471
|
account_id: response.account && response.account.id || opts.accountFlag || null, path: file,
|
|
466
472
|
tags: document.tags, sha256: digest, binding_created: true,
|
|
@@ -533,21 +539,32 @@ async function push(opts, client) {
|
|
|
533
539
|
throw new CloudCommandError('unsafe_local_state',
|
|
534
540
|
'file is not bound; provide both --document and --base-revision');
|
|
535
541
|
}
|
|
536
|
-
const
|
|
537
|
-
const digest = bindings.hash(
|
|
542
|
+
const localContent = fs.readFileSync(file, 'utf8');
|
|
543
|
+
const digest = bindings.hash(localContent);
|
|
538
544
|
if (binding.content_sha256 === digest) {
|
|
539
545
|
return emit(opts, 'cloud.push', { document_id: binding.document_id,
|
|
540
546
|
base_revision_id: binding.revision_id, revision_id: binding.revision_id,
|
|
541
547
|
sha256: digest, no_change: true }, 'No changes to push.');
|
|
542
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);
|
|
543
562
|
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 !==
|
|
563
|
+
if (!pending || pending.document_id !== binding.document_id || pending.base_revision_id !== binding.revision_id || pending.sha256 !== uploadDigest) {
|
|
545
564
|
pending = { document_id: binding.document_id, base_revision_id: binding.revision_id,
|
|
546
|
-
sha256:
|
|
565
|
+
sha256: uploadDigest, idempotency_key: crypto.randomUUID() };
|
|
547
566
|
bindings.setPending(credential.user_id, file, pending);
|
|
548
567
|
}
|
|
549
|
-
const targetMarkdown = bindings.readBase(credential.user_id, binding.document_id,
|
|
550
|
-
binding.revision_id);
|
|
551
568
|
const response = await client.authenticated('/api/cloud/v1/documents/' + encodeURIComponent(binding.document_id) + '/revisions', {
|
|
552
569
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
553
570
|
body: JSON.stringify({ target_revision_id: binding.revision_id,
|
|
@@ -557,17 +574,27 @@ async function push(opts, client) {
|
|
|
557
574
|
const document = response.document;
|
|
558
575
|
const localChanged = bindings.hash(fs.readFileSync(file)) !== digest;
|
|
559
576
|
const savedContent = typeof document.markdown === 'string' ? document.markdown : content;
|
|
560
|
-
|
|
561
|
-
|
|
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;
|
|
562
589
|
if (!localChanged) {
|
|
563
|
-
if (localUpdated) atomicFileWrite(file,
|
|
590
|
+
if (localUpdated) atomicFileWrite(file, savedLocalContent);
|
|
564
591
|
bindings.set(credential.user_id, file, { document_id: document.id,
|
|
565
592
|
revision_id: document.current_revision_id, content_sha256: savedDigest,
|
|
566
593
|
updated_at: document.updated_at });
|
|
567
594
|
}
|
|
568
595
|
bindings.cacheBase(credential.user_id, document.id, document.current_revision_id, savedContent);
|
|
569
596
|
bindings.clearPending(credential.user_id, file);
|
|
570
|
-
emit(opts, 'cloud.push', { document_id: document.id, base_revision_id: binding.revision_id,
|
|
597
|
+
return emit(opts, 'cloud.push', { document_id: document.id, base_revision_id: binding.revision_id,
|
|
571
598
|
revision_id: document.current_revision_id, revision_number: document.revision_number,
|
|
572
599
|
tags: document.tags, sha256: savedDigest, no_change: false,
|
|
573
600
|
merge_classification: document.merge_classification || 'clean',
|
|
@@ -579,6 +606,64 @@ async function push(opts, client) {
|
|
|
579
606
|
: localUpdated ? ' and updated the local file with Cloud changes.' : '.'));
|
|
580
607
|
}
|
|
581
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
|
+
|
|
582
667
|
async function history(opts, client) {
|
|
583
668
|
const documentId = opts.extra;
|
|
584
669
|
if (!documentId) throw new CloudCommandError('invalid_request',
|
|
@@ -737,5 +822,5 @@ async function runCloudCommand(opts, dependencies) {
|
|
|
737
822
|
}
|
|
738
823
|
}
|
|
739
824
|
|
|
740
|
-
module.exports = { CloudClient, CloudCommandError, runCloudCommand, filterTags, origin,
|
|
741
|
-
CLOUD_OVERVIEW, entitlementFailure, skillInstallCommand };
|
|
825
|
+
module.exports = { CloudClient, CloudCommandError, runCloudCommand, autoSyncOpen, filterTags, origin,
|
|
826
|
+
EXIT, CLOUD_HELP, CLOUD_OVERVIEW, entitlementFailure, skillInstallCommand };
|
package/lib/cloud-credentials.js
CHANGED
package/lib/commands.js
CHANGED
|
@@ -44,6 +44,7 @@ async function finishUrl(opts, content, local, defaults) {
|
|
|
44
44
|
try {
|
|
45
45
|
const url = await buildShortUrl(content, {
|
|
46
46
|
url: opts.url, mode: opts.mode, theme: opts.theme, section: opts.section,
|
|
47
|
+
source: opts.sourceFlag, placement: opts.placementFlag,
|
|
47
48
|
});
|
|
48
49
|
return { url, contentPresent: !!content };
|
|
49
50
|
} catch (e) {
|
|
@@ -186,7 +187,7 @@ async function prepareUrl(opts) {
|
|
|
186
187
|
// The non-blocking, share-by-URL case is `sdoc share <file>`.
|
|
187
188
|
async function openCommand(opts) {
|
|
188
189
|
const { url } = await prepareUrl(opts);
|
|
189
|
-
openBrowser(url);
|
|
190
|
+
if (!opts.noOpenFlag) openBrowser(url);
|
|
190
191
|
console.log(`SDocs → ${url.length > 80 ? url.slice(0, 77) + '...' : url}`);
|
|
191
192
|
await postCommandHooks();
|
|
192
193
|
}
|
package/lib/help-text.js
CHANGED
|
@@ -20,6 +20,10 @@ USAGE
|
|
|
20
20
|
sdoc share <file> Copy shareable link to clipboard
|
|
21
21
|
sdoc share <file> --section "X" Link with section anchor
|
|
22
22
|
sdoc share <file> --short Encrypted /s/<id> short link (see SHORT LINKS)
|
|
23
|
+
sdoc share <file> --short --source email-launch
|
|
24
|
+
Short link attributed to a named source
|
|
25
|
+
sdoc share <file> --short --source x --placement launch-reply-01
|
|
26
|
+
Social link attributed to one placement
|
|
23
27
|
sdoc schema Print the full styles schema
|
|
24
28
|
sdoc charts Chart types, options, and styling guide
|
|
25
29
|
sdoc diagrams Mermaid diagrams reference (\`\`\`mermaid blocks)
|
|
@@ -55,6 +59,9 @@ USAGE
|
|
|
55
59
|
sdoc defaults Show ~/.sdocs/styles.yaml
|
|
56
60
|
sdoc defaults --reset Remove default styles
|
|
57
61
|
sdoc setup Install the SmallDocs skill for your coding agents
|
|
62
|
+
sdoc setup --cloud --yes Enable Cloud-first opens and install the Cloud skill
|
|
63
|
+
sdoc setup --cloud --account ID Use one Cloud account for automatic creates
|
|
64
|
+
sdoc setup --standard --yes Return to local-first behavior
|
|
58
65
|
sdoc refresh Refresh the SmallDocs skill to the current version
|
|
59
66
|
sdoc auto-update [on|off] Toggle auto-install of sdoc updates
|
|
60
67
|
sdoc upgrade Upgrade sdoc to the latest version now
|
|
@@ -83,7 +90,12 @@ OPTIONS
|
|
|
83
90
|
--mode <m> Alias for --read / --write / --style / --raw / --comment
|
|
84
91
|
--short Use the encrypted /s/<id> short-URL form (share
|
|
85
92
|
subcommand only). See SHORT LINKS below.
|
|
93
|
+
--source <name> Attribute short-link visits to a source or campaign.
|
|
94
|
+
--placement <id> Attribute a social short link to one publishing
|
|
95
|
+
placement. Use 4-32 letters, numbers, underscores,
|
|
96
|
+
or hyphens with --source x, yt, or youtube.
|
|
86
97
|
--json Machine-readable output for safe and Cloud commands.
|
|
98
|
+
--no-open Do not launch the browser after a normal file open.
|
|
87
99
|
--audit Also print GitHub links to server-side source
|
|
88
100
|
files (safe subcommand only).
|
|
89
101
|
--keep-open feedback subcommand: keep the bridge alive across
|
|
@@ -202,6 +214,21 @@ SHORT LINKS (sdoc share --short)
|
|
|
202
214
|
|
|
203
215
|
\`--short\` produces a shorter, encrypted form: \`https://smalldocs.org/s/<id>#k=<key>\`.
|
|
204
216
|
|
|
217
|
+
Add any source name when publishing a link, for example
|
|
218
|
+
\`--source email-launch\`, \`--source product-hunt\`, or \`--source x\`.
|
|
219
|
+
This produces a URL such as
|
|
220
|
+
\`https://smalldocs.org/s/<id>?src=email-launch#k=<key>\`. The source appears
|
|
221
|
+
automatically at \`/analytics/sources\`; the visit still counts in the
|
|
222
|
+
ordinary visit and short-link totals.
|
|
223
|
+
|
|
224
|
+
Add \`--placement launch-reply-01\` to an X or YouTube source link
|
|
225
|
+
when the same document is published in more than one place. This produces
|
|
226
|
+
\`https://smalldocs.org/s/<id>?src=x&pid=launch-reply-01#k=<key>\`.
|
|
227
|
+
The placement id is public and identifies the post, reply, or comment where
|
|
228
|
+
the link was used. It does not identify a visitor. Only links with a valid
|
|
229
|
+
placement id appear in the targeted-placement table at
|
|
230
|
+
\`/analytics/sources?type=short\`.
|
|
231
|
+
|
|
205
232
|
How it works:
|
|
206
233
|
1. The CLI brotli-compresses the content, generates a 256-bit AES-GCM
|
|
207
234
|
key + 96-bit nonce locally, and encrypts the compressed bytes.
|
|
@@ -218,6 +245,8 @@ SHORT LINKS (sdoc share --short)
|
|
|
218
245
|
What the server can see:
|
|
219
246
|
- That a ciphertext blob was uploaded under id <id>.
|
|
220
247
|
- When it was fetched and from which IP (standard server logs).
|
|
248
|
+
- For an opted-in placement link, its source, public placement id, and
|
|
249
|
+
short-link id when a visit is counted.
|
|
221
250
|
What the server cannot see:
|
|
222
251
|
- The plaintext document. It does not have the key.
|
|
223
252
|
- The key. The key never leaves the URL fragment.
|
package/lib/io.js
CHANGED
|
@@ -39,6 +39,8 @@ function parseArgs(argv) {
|
|
|
39
39
|
let url = null;
|
|
40
40
|
let subcommand = null;
|
|
41
41
|
let section = null;
|
|
42
|
+
let sourceFlag = null;
|
|
43
|
+
let placementFlag = null;
|
|
42
44
|
let theme = null;
|
|
43
45
|
let resetFlag = false;
|
|
44
46
|
let shortFlag = false;
|
|
@@ -55,6 +57,7 @@ function parseArgs(argv) {
|
|
|
55
57
|
let helpFlag = false;
|
|
56
58
|
let yesFlag = false;
|
|
57
59
|
let dryRunFlag = false;
|
|
60
|
+
let skillEdition = null;
|
|
58
61
|
let sheetName = null;
|
|
59
62
|
let projectFlag = null;
|
|
60
63
|
let accountFlag = null;
|
|
@@ -115,6 +118,8 @@ function parseArgs(argv) {
|
|
|
115
118
|
|
|
116
119
|
if (arg === '--url') { url = args[++i]; continue; }
|
|
117
120
|
if (arg === '--section' || arg === '-s') { section = args[++i]; continue; }
|
|
121
|
+
if (arg === '--source') { sourceFlag = args[++i]; continue; }
|
|
122
|
+
if (arg === '--placement') { placementFlag = args[++i]; continue; }
|
|
118
123
|
if (arg === '--reset') { resetFlag = true; continue; }
|
|
119
124
|
if (arg === '--short') { shortFlag = true; continue; }
|
|
120
125
|
if (arg === '--json') { jsonFlag = true; continue; }
|
|
@@ -132,6 +137,8 @@ function parseArgs(argv) {
|
|
|
132
137
|
if (arg === '--tags') { tagsFlag = true; continue; }
|
|
133
138
|
if (arg === '--yes' || arg === '-y') { yesFlag = true; continue; }
|
|
134
139
|
if (arg === '--dry-run') { dryRunFlag = true; continue; }
|
|
140
|
+
if (arg === '--cloud') { skillEdition = 'cloud'; continue; }
|
|
141
|
+
if (arg === '--standard') { skillEdition = 'standard'; continue; }
|
|
135
142
|
if (arg === '--sheet') { sheetName = args[++i]; continue; }
|
|
136
143
|
if (arg === '--project') { projectFlag = args[++i]; continue; }
|
|
137
144
|
if (arg === '--account') { accountFlag = args[++i]; continue; }
|
|
@@ -198,11 +205,11 @@ function parseArgs(argv) {
|
|
|
198
205
|
}
|
|
199
206
|
|
|
200
207
|
return {
|
|
201
|
-
file, extra, mode, url, subcommand, section, theme,
|
|
208
|
+
file, extra, mode, url, subcommand, section, sourceFlag, placementFlag, theme,
|
|
202
209
|
resetFlag, shortFlag, jsonFlag, auditFlag, waitFlag,
|
|
203
210
|
messageText, connectTimeoutS, idleTimeoutS, reconnectGraceMs,
|
|
204
211
|
keepOpenFlag, logFile,
|
|
205
|
-
tagsFlag, helpFlag, yesFlag, dryRunFlag, sheetName,
|
|
212
|
+
tagsFlag, helpFlag, yesFlag, dryRunFlag, skillEdition, sheetName,
|
|
206
213
|
projectFlag, accountFlag, outputPath, revisionFlag, documentFlag, baseRevisionFlag,
|
|
207
214
|
limitFlag, noOpenFlag, noBindFlag, forceFlag, tagFilters,
|
|
208
215
|
everyoneFlag, onlyYouFlag, sharedWithMeFlag, noteText, memberFlags, documentFlags,
|
package/lib/setup.js
CHANGED
|
@@ -20,6 +20,7 @@ const {
|
|
|
20
20
|
SKILL_BODY,
|
|
21
21
|
SKILL_NAME,
|
|
22
22
|
formatSkill,
|
|
23
|
+
readSkillEdition,
|
|
23
24
|
canonicalSkillFile,
|
|
24
25
|
canonicalSkillDir,
|
|
25
26
|
legacyBlockTargets,
|
|
@@ -80,12 +81,13 @@ async function askAutoRefreshConsent() {
|
|
|
80
81
|
// Preview what setup would do: print the skill, the symlinks it would create,
|
|
81
82
|
// the agents covered by the canonical copy, and any legacy blocks it would
|
|
82
83
|
// strip. Touches no file and writes no state.
|
|
83
|
-
function dryRunPreview() {
|
|
84
|
+
function dryRunPreview(edition) {
|
|
84
85
|
const home = os.homedir();
|
|
85
86
|
const env = process.env;
|
|
87
|
+
edition = edition || installedEdition(home);
|
|
86
88
|
const skillPath = canonicalSkillFile(home);
|
|
87
89
|
console.log(`--- ${skillPath} ---`);
|
|
88
|
-
console.log(formatSkill(SKILL_VERSION));
|
|
90
|
+
console.log(formatSkill(SKILL_VERSION, { cloud: edition === 'cloud' }));
|
|
89
91
|
|
|
90
92
|
const detected = detectSkillAgents(home, env);
|
|
91
93
|
const linked = detected.filter(a => !a.universal);
|
|
@@ -116,7 +118,31 @@ function dryRunPreview() {
|
|
|
116
118
|
}
|
|
117
119
|
}
|
|
118
120
|
|
|
119
|
-
|
|
121
|
+
function installedEdition(home) {
|
|
122
|
+
try { return readSkillEdition(fs.readFileSync(canonicalSkillFile(home), 'utf8')); }
|
|
123
|
+
catch (_) { return 'standard'; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function setupStateFields(edition, accountId, existing) {
|
|
127
|
+
const activeEdition = edition || installedEdition(os.homedir());
|
|
128
|
+
return {
|
|
129
|
+
skillEdition: activeEdition,
|
|
130
|
+
cloudAccountId: activeEdition === 'cloud'
|
|
131
|
+
? (accountId || existing && existing.cloudAccountId || null) : null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function cloudFirstSettings() {
|
|
136
|
+
const state = readSetupState();
|
|
137
|
+
if (state) {
|
|
138
|
+
if (state.declined || state.skillEdition !== 'cloud') return null;
|
|
139
|
+
return { accountId: state.cloudAccountId || null };
|
|
140
|
+
}
|
|
141
|
+
return installedEdition(os.homedir()) === 'cloud' ? { accountId: null } : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function runSetup({ force = false, yes = false, dryRun = false,
|
|
145
|
+
edition = null, accountId = null } = {}) {
|
|
120
146
|
if (!force) {
|
|
121
147
|
if (!process.stdout.isTTY || !process.stdin.isTTY) return;
|
|
122
148
|
if (process.env.CI || process.env.SDOCS_NO_SETUP) return;
|
|
@@ -125,9 +151,10 @@ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
|
|
|
125
151
|
|
|
126
152
|
// ── --yes (non-interactive) path ───────────────────────────
|
|
127
153
|
if (yes) {
|
|
128
|
-
if (dryRun) { dryRunPreview(); return; }
|
|
154
|
+
if (dryRun) { dryRunPreview(edition); return; }
|
|
129
155
|
|
|
130
|
-
const
|
|
156
|
+
const existing = readSetupState();
|
|
157
|
+
const result = syncAgentSkill({ edition });
|
|
131
158
|
const changed = syncChanged(result);
|
|
132
159
|
const detected = detectSkillAgents(os.homedir(), process.env);
|
|
133
160
|
|
|
@@ -153,6 +180,7 @@ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
|
|
|
153
180
|
autoRefreshAgentFiles: true,
|
|
154
181
|
autoInstallUpdates: false,
|
|
155
182
|
lastRunVersion: VERSION,
|
|
183
|
+
...setupStateFields(edition, accountId, existing),
|
|
156
184
|
});
|
|
157
185
|
return;
|
|
158
186
|
}
|
|
@@ -181,7 +209,9 @@ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
|
|
|
181
209
|
|
|
182
210
|
const RULE = '\u2550'.repeat(36);
|
|
183
211
|
console.log(`\n${RULE} Skill body ${RULE}`);
|
|
184
|
-
|
|
212
|
+
const displayEdition = edition || installedEdition(home);
|
|
213
|
+
console.log(formatSkill(SKILL_VERSION, { cloud: displayEdition === 'cloud' })
|
|
214
|
+
.replace(/^---[\s\S]*?---\n\n<!--[^\n]+-->\n<!--[^\n]+-->\n/, '').trim());
|
|
185
215
|
console.log(RULE);
|
|
186
216
|
|
|
187
217
|
const a = await ask('\nInstall? [Y/n/skip] ');
|
|
@@ -192,12 +222,14 @@ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
|
|
|
192
222
|
writtenTo: [], declined: true,
|
|
193
223
|
autoRefreshAgentFiles: false, autoInstallUpdates: false,
|
|
194
224
|
lastRunVersion: VERSION,
|
|
225
|
+
...setupStateFields(edition, accountId, readSetupState()),
|
|
195
226
|
});
|
|
196
227
|
console.log('Skipped. Run `sdoc setup` any time to revisit.');
|
|
197
228
|
return;
|
|
198
229
|
}
|
|
199
230
|
|
|
200
|
-
const
|
|
231
|
+
const existing = readSetupState();
|
|
232
|
+
const result = syncAgentSkill({ edition });
|
|
201
233
|
const changed = syncChanged(result);
|
|
202
234
|
if (changed || result.errors.length) printSyncSummary(result);
|
|
203
235
|
if (result.errors.length) return;
|
|
@@ -212,6 +244,7 @@ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
|
|
|
212
244
|
autoRefreshAgentFiles: autoRefresh,
|
|
213
245
|
autoInstallUpdates: autoInstall,
|
|
214
246
|
lastRunVersion: VERSION,
|
|
247
|
+
...setupStateFields(edition, accountId, existing),
|
|
215
248
|
});
|
|
216
249
|
console.log('\nDone. Run `sdoc setup` any time to revisit.');
|
|
217
250
|
}
|
|
@@ -237,7 +270,7 @@ async function maybeAutoRefresh() {
|
|
|
237
270
|
const next = implicitConsentState(toImplicitResults(result), VERSION);
|
|
238
271
|
if (!next) return;
|
|
239
272
|
printSyncSummary(result);
|
|
240
|
-
writeSetupState(next);
|
|
273
|
+
writeSetupState({ ...next, ...setupStateFields(null, null, null) });
|
|
241
274
|
return;
|
|
242
275
|
}
|
|
243
276
|
|
|
@@ -271,6 +304,7 @@ async function runRefresh() {
|
|
|
271
304
|
autoRefreshAgentFiles: existing ? existing.autoRefreshAgentFiles !== false : true,
|
|
272
305
|
autoInstallUpdates: existing && existing.autoInstallUpdates != null ? existing.autoInstallUpdates : false,
|
|
273
306
|
lastRunVersion: VERSION,
|
|
307
|
+
...setupStateFields(null, null, existing),
|
|
274
308
|
});
|
|
275
309
|
}
|
|
276
310
|
|
|
@@ -304,4 +338,5 @@ module.exports = {
|
|
|
304
338
|
runRefresh,
|
|
305
339
|
runAutoUpdateSubcommand,
|
|
306
340
|
maybeAutoRefresh,
|
|
341
|
+
cloudFirstSettings,
|
|
307
342
|
};
|
package/lib/short-link.js
CHANGED
|
@@ -18,6 +18,35 @@ const { slugify } = require('../shared/sdocs-slugify.js');
|
|
|
18
18
|
const { toBase64Url } = require('./url');
|
|
19
19
|
const { DEFAULT_URL } = require('./constants');
|
|
20
20
|
|
|
21
|
+
const PLACEMENT_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{3,31}$/;
|
|
22
|
+
const PLACEMENT_SOURCES = new Set(['x', 'yt', 'youtube']);
|
|
23
|
+
|
|
24
|
+
function normalizePlacementId(value) {
|
|
25
|
+
if (value == null || value === '') return '';
|
|
26
|
+
const placement = String(value).trim();
|
|
27
|
+
if (!PLACEMENT_RE.test(placement)) {
|
|
28
|
+
throw new Error('placement must be 4-32 letters, numbers, underscores, or hyphens');
|
|
29
|
+
}
|
|
30
|
+
return placement;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildAttributionQuery(sourceValue, placementValue) {
|
|
34
|
+
const source = String(sourceValue || '').trim().toLowerCase();
|
|
35
|
+
const placement = normalizePlacementId(placementValue);
|
|
36
|
+
if (placement && !PLACEMENT_SOURCES.has(source)) {
|
|
37
|
+
throw new Error('placement requires --source x, yt, or youtube');
|
|
38
|
+
}
|
|
39
|
+
const query = new URLSearchParams();
|
|
40
|
+
if (source) query.set('src', source);
|
|
41
|
+
if (placement) query.set('pid', placement);
|
|
42
|
+
return query.toString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assembleShortUrl(baseUrl, id, attributionQuery, fragmentParams) {
|
|
46
|
+
const sourceQuery = attributionQuery ? `?${attributionQuery}` : '';
|
|
47
|
+
return `${baseUrl}/s/${id}${sourceQuery}#${fragmentParams.toString()}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
21
50
|
// The blob format (nonce(12) + ciphertext + tag(16)) matches the browser.
|
|
22
51
|
function compressAndEncrypt(content) {
|
|
23
52
|
const compressed = zlib.brotliCompressSync(Buffer.from(content, 'utf-8'), {
|
|
@@ -72,6 +101,8 @@ function uploadShortLink(ciphertextB64, baseUrl) {
|
|
|
72
101
|
|
|
73
102
|
async function buildShortUrl(content, opts) {
|
|
74
103
|
if (!content) throw new Error('short link requires file content');
|
|
104
|
+
opts = opts || {};
|
|
105
|
+
const attributionQuery = buildAttributionQuery(opts.source, opts.placement);
|
|
75
106
|
|
|
76
107
|
// Mirror the hash-build's default-stripping so the encrypted payload is
|
|
77
108
|
// identical to what the browser would encode.
|
|
@@ -95,11 +126,17 @@ async function buildShortUrl(content, opts) {
|
|
|
95
126
|
if (opts.theme) params.set('theme', opts.theme);
|
|
96
127
|
if (opts.section) params.set('sec', slugify(opts.section));
|
|
97
128
|
|
|
98
|
-
|
|
129
|
+
// Source attribution belongs in the query so the server can receive it. The
|
|
130
|
+
// encryption key stays after # and therefore never leaves the browser. The
|
|
131
|
+
// short-link id and encrypted payload are unchanged by this label.
|
|
132
|
+
return assembleShortUrl(baseUrl, id, attributionQuery, params);
|
|
99
133
|
}
|
|
100
134
|
|
|
101
135
|
module.exports = {
|
|
102
136
|
compressAndEncrypt,
|
|
103
137
|
uploadShortLink,
|
|
104
138
|
buildShortUrl,
|
|
139
|
+
normalizePlacementId,
|
|
140
|
+
buildAttributionQuery,
|
|
141
|
+
assembleShortUrl,
|
|
105
142
|
};
|