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/lib/setup.js CHANGED
@@ -1,24 +1,31 @@
1
1
  // `sdoc setup`, `sdoc refresh`, `sdoc auto-update`, and the implicit
2
- // post-command refresh that keeps agent files in sync as new sdoc
2
+ // post-command refresh that keeps the SmallDocs skill current as new sdoc
3
3
  // versions ship.
4
4
  //
5
- // runSetup: first-run interactive flow. Detects agent configs, writes
6
- // the block into the ones the user agrees to. Pass dryRun:true to
7
- // preview what would be written without touching any file or state.
8
- // runRefresh: unconditional refresh of every agent file that already
9
- // has a recognised block.
5
+ // runSetup: first-run interactive flow. Detects installed agents, installs
6
+ // the skill (canonical copy + symlinks), strips legacy blocks. Pass
7
+ // dryRun:true to preview without touching anything.
8
+ // runRefresh: unconditional refresh of the canonical skill + symlinks.
10
9
  // runAutoUpdateSubcommand: flips state.autoInstallUpdates.
11
10
  // maybeAutoRefresh: called after every successful command. Quiet, only
12
- // touches files whose existing block we already manage.
11
+ // rewrites the canonical skill when its version is stale.
13
12
 
14
- const os = require('os');
13
+ const os = require('os');
15
14
  const path = require('path');
15
+ const fs = require('fs');
16
16
  const readline = require('readline');
17
17
 
18
18
  const {
19
- AGENT_BLOCK_VERSION,
20
- AGENT_BLOCK_BODY,
21
- formatAgentBlock,
19
+ SKILL_VERSION,
20
+ SKILL_BODY,
21
+ SKILL_NAME,
22
+ formatSkill,
23
+ readSkillEdition,
24
+ canonicalSkillFile,
25
+ canonicalSkillDir,
26
+ legacyBlockTargets,
27
+ findBookendedBlock,
28
+ findLegacyBlock,
22
29
  compareVersions,
23
30
  readSetupState,
24
31
  writeSetupState,
@@ -28,11 +35,12 @@ const {
28
35
  const { upgradeCommand } = require('./update-check');
29
36
 
30
37
  const {
31
- detectAgents,
32
- fileHasBlock,
33
- writeBookendedBlock,
34
- refreshAllAgentFiles,
35
- printRefreshSummary,
38
+ detectSkillAgents,
39
+ hasSetupEvidence,
40
+ syncAgentSkill,
41
+ syncChanged,
42
+ toImplicitResults,
43
+ printSyncSummary,
36
44
  } = require('./agent-files');
37
45
 
38
46
  const { VERSION, AGENT_CHANGES_URL } = require('./constants');
@@ -59,19 +67,82 @@ async function askAutoInstallConsent() {
59
67
  }
60
68
 
61
69
  async function askAutoRefreshConsent() {
62
- console.log('\nKeep this block updated on future sdoc upgrades?');
70
+ console.log('\nKeep this skill updated on future sdoc upgrades?');
63
71
  console.log('');
64
- console.log('When sdoc adds a feature we sometimes update this section so');
65
- console.log('your agent learns about it. Each time the block changes we');
66
- console.log(`print a notice with a link to ${AGENT_CHANGES_URL}`);
67
- console.log('showing the exact delta - the new wording, and why it changed.');
72
+ console.log('When sdoc adds a feature we sometimes update the skill so your');
73
+ console.log('agent learns about it. Each change prints a notice with a link to');
74
+ console.log(`${AGENT_CHANGES_URL} showing the exact delta - the new wording, and why.`);
68
75
  console.log('');
69
76
  console.log('Re-run `sdoc setup` any time to change this.\n');
70
77
  const a = await ask('Enable? [Y/n] ');
71
78
  return !a || a === 'y' || a === 'yes';
72
79
  }
73
80
 
74
- async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
81
+ // Preview what setup would do: print the skill, the symlinks it would create,
82
+ // the agents covered by the canonical copy, and any legacy blocks it would
83
+ // strip. Touches no file and writes no state.
84
+ function dryRunPreview(edition) {
85
+ const home = os.homedir();
86
+ const env = process.env;
87
+ edition = edition || installedEdition(home);
88
+ const skillPath = canonicalSkillFile(home);
89
+ console.log(`--- ${skillPath} ---`);
90
+ console.log(formatSkill(SKILL_VERSION, { cloud: edition === 'cloud' }));
91
+
92
+ const detected = detectSkillAgents(home, env);
93
+ const linked = detected.filter(a => !a.universal);
94
+ const universal = detected.filter(a => a.universal);
95
+
96
+ if (universal.length) {
97
+ console.log('\nCovered by the canonical copy (~/.agents/skills, no symlink needed):');
98
+ for (const a of universal) console.log(` ${a.displayName}`);
99
+ }
100
+ if (linked.length) {
101
+ console.log('\nSymlinks to create (<agent skills dir> -> canonical):');
102
+ for (const a of linked) console.log(` ${path.join(a.dir, SKILL_NAME)} -> ${canonicalSkillDir(home)}`);
103
+ }
104
+ if (detected.length === 0) {
105
+ console.log('\nNo coding-agent configs detected. The canonical skill is still written');
106
+ console.log('so any agent that discovers ~/.agents/skills picks it up.');
107
+ }
108
+
109
+ const wouldStrip = [];
110
+ for (const t of legacyBlockTargets(home, env)) {
111
+ let content;
112
+ try { content = fs.readFileSync(t.file, 'utf-8'); } catch (_) { continue; }
113
+ if (findBookendedBlock(content) || findLegacyBlock(content)) wouldStrip.push(t.file);
114
+ }
115
+ if (wouldStrip.length) {
116
+ console.log('\nLegacy SmallDocs blocks to remove:');
117
+ for (const f of wouldStrip) console.log(` ${f}`);
118
+ }
119
+ }
120
+
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 } = {}) {
75
146
  if (!force) {
76
147
  if (!process.stdout.isTTY || !process.stdin.isTTY) return;
77
148
  if (process.env.CI || process.env.SDOCS_NO_SETUP) return;
@@ -79,143 +150,71 @@ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
79
150
  }
80
151
 
81
152
  // ── --yes (non-interactive) path ───────────────────────────
82
- // Pulled out of the detection branch so this is the SINGLE place that
83
- // handles every --yes case: fresh install, old block (upgrade), legacy
84
- // open-marker (migration), already-current (no-op), no agents at all.
85
- // Idempotent by design - an agent or user can re-paste the install prompt
86
- // any number of times and the result is a current block in every detected
87
- // config, or a clean "nothing to do".
88
153
  if (yes) {
89
- // ── --dry-run (preview only) path ─────────────────────────────────────
90
- // Prints each file path and the block that would be written, then exits
91
- // without touching any file or mutating setup state. Must return before
92
- // the write steps below.
93
- if (dryRun) {
94
- const toWrite = detectAgents().filter(t => !fileHasBlock(t.filePath));
95
- if (toWrite.length === 0) {
96
- console.log('All SDocs agent blocks already at current version. Nothing to do.');
97
- return;
98
- }
99
- for (const t of toWrite) {
100
- console.log(`--- ${t.filePath} ---`);
101
- console.log(formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY));
102
- }
103
- return;
104
- }
154
+ if (dryRun) { dryRunPreview(edition); return; }
155
+
156
+ const existing = readSetupState();
157
+ const result = syncAgentSkill({ edition });
158
+ const changed = syncChanged(result);
159
+ const detected = detectSkillAgents(os.homedir(), process.env);
105
160
 
106
- // Step 1: refresh any existing outdated / legacy blocks. This is what
107
- // closes the gap where re-running setup --yes used to silently no-op
108
- // on a stale install.
109
- const refreshResults = refreshAllAgentFiles();
110
- const refreshedFiles = refreshResults.filter(r => r.changed).map(r => r.path);
111
- if (refreshResults.some(r => r.changed)) printRefreshSummary(refreshResults);
112
-
113
- // Step 2: any agent whose config dir exists but doesn't yet have a
114
- // block gets one written. Re-detect after refresh because the refresh
115
- // step may have flipped some files from "needs block" to "has block".
116
- const stillMissing = detectAgents().filter(t => !fileHasBlock(t.filePath));
117
- const writtenTo = [];
118
- for (const t of stillMissing) {
119
- try { writeBookendedBlock(t.filePath); writtenTo.push(t.filePath); console.log(`✓ ${t.name}: ${t.filePath}`); }
120
- catch (e) { console.error(`✗ ${t.name}: ${e.message}`); }
161
+ if (changed || result.errors.length) {
162
+ printSyncSummary(result);
121
163
  }
164
+ if (result.errors.length) return;
122
165
 
123
- const affected = [...new Set([...writtenTo, ...refreshedFiles])];
124
-
125
- if (affected.length === 0) {
126
- const anyAgentDir = detectAgents().length > 0;
127
- writeSetupState({
128
- setupCompleted: new Date().toISOString(),
129
- writtenTo: [], declined: !anyAgentDir,
130
- autoRefreshAgentFiles: anyAgentDir,
131
- autoInstallUpdates: false,
132
- lastRunVersion: VERSION,
133
- });
134
- if (anyAgentDir) {
135
- console.log('All SDocs agent blocks already at current version. Nothing to do.');
166
+ if (!changed) {
167
+ if (detected.length > 0) {
168
+ console.log('SmallDocs skill already at current version. Nothing to do.');
136
169
  } else {
137
- console.log('No coding-agent configs detected. Nothing to write.');
138
- console.log('Re-run `sdoc setup` (interactive) if you want to include opencode.');
170
+ console.log('No coding-agent configs detected. The canonical skill is at');
171
+ console.log('~/.agents/skills/smalldocs/SKILL.md; any agent that discovers');
172
+ console.log('~/.agents/skills will pick it up.');
139
173
  }
140
- return;
141
174
  }
142
175
 
143
176
  writeSetupState({
144
177
  setupCompleted: new Date().toISOString(),
145
- writtenTo: affected, declined: false,
178
+ writtenTo: changed ? [canonicalSkillFile(os.homedir())] : [],
179
+ declined: false,
146
180
  autoRefreshAgentFiles: true,
147
181
  autoInstallUpdates: false,
148
182
  lastRunVersion: VERSION,
183
+ ...setupStateFields(edition, accountId, existing),
149
184
  });
150
- const n = affected.length;
151
- const verb = writtenTo.length && refreshedFiles.length
152
- ? 'Wrote/refreshed'
153
- : (writtenTo.length ? 'Wrote' : 'Refreshed');
154
- console.log(`\nDone. ${verb} SDocs block in ${n} ${n === 1 ? 'file' : 'files'}.`);
155
185
  return;
156
186
  }
157
187
 
158
- const detected = detectAgents().filter(t => !fileHasBlock(t.filePath));
159
-
160
- if (detected.length === 0) {
161
- const opencodeAlreadyDone = fileHasBlock(path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md'));
162
- if (opencodeAlreadyDone) {
163
- writeSetupState({
164
- setupCompleted: new Date().toISOString(),
165
- writtenTo: [], declined: false,
166
- autoRefreshAgentFiles: true, autoInstallUpdates: false,
167
- lastRunVersion: VERSION,
168
- });
169
- console.log('\nSDocs is already set up in all detected agent configs. Nothing to do.');
170
- return;
171
- }
172
- console.log('\n✨─────── SDocs setup ───────✨');
173
- console.log('First run only - wire SDocs into your CLI coding agents.\n');
174
- console.log('No coding-agent configs detected.');
175
- const a = await ask('Do you use opencode? [y/N] ');
176
- const writtenTo = [];
177
- let autoRefresh = false;
178
- let autoInstall = false;
179
- if (a === 'y' || a === 'yes') {
180
- const target = path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md');
181
- try { writeBookendedBlock(target); writtenTo.push(target); console.log(`✓ Wrote SDocs section to ${target}`); }
182
- catch (e) { console.error(`Failed to write ${target}: ${e.message}`); }
183
- autoRefresh = await askAutoRefreshConsent();
184
- autoInstall = await askAutoInstallConsent();
185
- console.log('Done. Run `sdoc setup` any time to revisit.');
186
- } else {
187
- console.log('Skipped. Run `sdoc setup` any time to revisit.');
188
- }
189
- writeSetupState({
190
- setupCompleted: new Date().toISOString(),
191
- writtenTo, declined: writtenTo.length === 0,
192
- autoRefreshAgentFiles: autoRefresh,
193
- autoInstallUpdates: autoInstall,
194
- lastRunVersion: VERSION,
195
- });
196
- return;
188
+ // ── interactive path ───────────────────────────────────────
189
+ const home = os.homedir();
190
+ const detected = detectSkillAgents(home, process.env);
191
+
192
+ console.log('\n\u2728─────── SmallDocs setup ───────\u2728');
193
+ console.log('Install the SmallDocs skill so your coding agents know `sdoc`.\n');
194
+
195
+ if (detected.length > 0) {
196
+ console.log('Detected: ' + detected.map(a => a.displayName).join(', '));
197
+ console.log('\nWill write the skill to ~/.agents/skills/smalldocs/SKILL.md.');
198
+ console.log('Agents using that universal location read it directly; other');
199
+ console.log('detected agents receive a symlink in their skills directory.');
200
+ } else {
201
+ console.log('No coding-agent configs detected. Setup still writes the canonical');
202
+ console.log('skill at ~/.agents/skills/smalldocs/SKILL.md, which any agent that');
203
+ console.log('discovers ~/.agents/skills will pick up.');
197
204
  }
198
-
199
- console.log('\n✨─────── SDocs setup ───────✨');
200
- console.log('First run only - wire SDocs into your CLI coding agents.\n');
201
- console.log('Detected: ' + detected.map(t => t.name).join(', '));
202
- console.log('\nWill append a short SDocs section to:');
203
- for (const t of detected) console.log(' ' + t.filePath);
204
- console.log('\nThese files are loaded into every conversation across all your');
205
- console.log('projects, so SDocs becomes available no matter where you\'re working.');
206
- console.log('');
207
- console.log('You can ask your agent things like:');
205
+ console.log('\nYou can ask your agent things like:');
208
206
  console.log(' "write up the plan and sdoc it to me"');
209
207
  console.log(' "explain async/await to me in a sdoc"');
210
208
  console.log(' "draft the release notes as a sdoc I can share"');
211
- console.log('');
212
- console.log('This is the best way to work with SDocs');
213
- const RULE = '═'.repeat(36);
214
- console.log(`\n═══════════ Block to add ═══════════`);
215
- console.log(AGENT_BLOCK_BODY.trim());
209
+
210
+ const RULE = '\u2550'.repeat(36);
211
+ console.log(`\n${RULE} Skill body ${RULE}`);
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());
216
215
  console.log(RULE);
217
216
 
218
- const a = await ask('\nAdd to all? [Y/n/skip] ');
217
+ const a = await ask('\nInstall? [Y/n/skip] ');
219
218
  const skipped = a === 'skip' || (a && a !== 'y' && a !== 'yes');
220
219
  if (skipped) {
221
220
  writeSetupState({
@@ -223,103 +222,93 @@ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
223
222
  writtenTo: [], declined: true,
224
223
  autoRefreshAgentFiles: false, autoInstallUpdates: false,
225
224
  lastRunVersion: VERSION,
225
+ ...setupStateFields(edition, accountId, readSetupState()),
226
226
  });
227
227
  console.log('Skipped. Run `sdoc setup` any time to revisit.');
228
228
  return;
229
229
  }
230
230
 
231
- const writtenTo = [];
232
- for (const t of detected) {
233
- try { writeBookendedBlock(t.filePath); writtenTo.push(t.filePath); console.log(`✓ ${t.name}: ${t.filePath}`); }
234
- catch (e) { console.error(`✗ ${t.name}: ${e.message}`); }
235
- }
231
+ const existing = readSetupState();
232
+ const result = syncAgentSkill({ edition });
233
+ const changed = syncChanged(result);
234
+ if (changed || result.errors.length) printSyncSummary(result);
235
+ if (result.errors.length) return;
236
236
 
237
- const autoRefresh = writtenTo.length > 0 ? await askAutoRefreshConsent() : false;
238
- const autoInstall = writtenTo.length > 0 ? await askAutoInstallConsent() : false;
237
+ const autoRefresh = await askAutoRefreshConsent();
238
+ const autoInstall = await askAutoInstallConsent();
239
239
 
240
240
  writeSetupState({
241
241
  setupCompleted: new Date().toISOString(),
242
- writtenTo, declined: false,
242
+ writtenTo: changed ? [canonicalSkillFile(home)] : [],
243
+ declined: false,
243
244
  autoRefreshAgentFiles: autoRefresh,
244
245
  autoInstallUpdates: autoInstall,
245
246
  lastRunVersion: VERSION,
247
+ ...setupStateFields(edition, accountId, existing),
246
248
  });
247
249
  console.log('\nDone. Run `sdoc setup` any time to revisit.');
248
250
  }
249
251
 
250
- // Auto-refresh existing agent files when the binary version is newer than the
251
- // version that last ran. No prompt: the user already consented during setup.
252
- // Bails on downgrades (block version > shipped version), errors, or partial
253
- // failures (lastRunVersion only advances when every changed file succeeded).
252
+ // Auto-refresh when the binary version is newer than the version that last
253
+ // ran. No prompt: the user already consented during setup. Rewrites only the
254
+ // canonical skill (every symlink follows); re-checks symlinks and re-strips
255
+ // any block that reappeared. Bails on downgrades or errors.
254
256
  async function maybeAutoRefresh() {
255
257
  if (process.env.SDOCS_NO_REFRESH) return;
256
258
  let state = readSetupState();
257
259
 
258
- // Implicit-consent migration for users who have a recognised SDocs block in
259
- // an agent file but no `~/.sdocs/setup.json`. This is the pre-1.5.0 install
260
- // path. `refreshContent` only signals `changed` for a block whose exact
261
- // shape we wrote (legacy JoshInLisbon terminator, or our bookend markers);
262
- // anything else is left untouched, so a user who deleted the block or
263
- // hand-edited it doesn't get state silently created.
260
+ // Implicit-consent migration for users who have evidence of a prior setup
261
+ // (a skill file on disk, or a recognised always-on block in one of the
262
+ // historical config files) but no ~/.sdocs/setup.json. A brand-new user has
263
+ // no evidence and is left for the interactive first-run prompt instead, so
264
+ // nothing is auto-installed without consent.
264
265
  if (!state) {
265
- const results = refreshAllAgentFiles();
266
- const next = implicitConsentState(results, VERSION);
266
+ if (!hasSetupEvidence(os.homedir(), process.env)) return;
267
+ const result = syncAgentSkill({});
268
+ if (result.errors.length) { printSyncSummary(result); return; }
269
+ if (!syncChanged(result)) return;
270
+ const next = implicitConsentState(toImplicitResults(result), VERSION);
267
271
  if (!next) return;
268
- printRefreshSummary(results);
269
- writeSetupState(next);
272
+ printSyncSummary(result);
273
+ writeSetupState({ ...next, ...setupStateFields(null, null, null) });
270
274
  return;
271
275
  }
272
276
 
273
277
  if (!state.autoRefreshAgentFiles) return;
274
278
  if (compareVersions(VERSION, state.lastRunVersion) <= 0) return;
275
279
 
276
- const results = refreshAllAgentFiles();
277
- const anyChanged = results.some(r => r.changed);
278
- if (anyChanged) printRefreshSummary(results);
280
+ const result = syncAgentSkill({});
281
+ if (syncChanged(result) || result.errors.length) printSyncSummary(result);
279
282
 
280
- const anyError = results.some(r => r.error);
281
- if (!anyError) {
283
+ if (!result.errors.length) {
282
284
  writeSetupState({ ...state, lastRunVersion: VERSION });
283
285
  }
284
286
  }
285
287
 
286
- // `sdoc refresh` unconditional agent-block refresh. Useful for users whose
287
- // setup.json was never written (pre-1.5.0 installs) or has been deleted, and
288
- // for agents that want to trigger the migration explicitly without going
289
- // through the interactive setup flow.
288
+ // `sdoc refresh` - unconditional skill refresh. Useful when setup.json was
289
+ // never written or has been deleted, or to force the migration explicitly.
290
290
  async function runRefresh() {
291
291
  const existing = readSetupState();
292
- const results = refreshAllAgentFiles();
293
- const changed = results.filter(r => r.changed);
294
- const errors = results.filter(r => r.error);
295
- const current = results.filter(r => r.reason === 'current');
296
- const blocksPresent = changed.length + current.length;
297
-
298
- printRefreshSummary(results);
299
-
300
- if (changed.length === 0 && errors.length === 0) {
301
- if (blocksPresent === 0) {
302
- console.log('No SDocs blocks found in any agent file. Run `sdoc setup` to add one.');
303
- return;
304
- }
305
- console.log(`All SDocs agent blocks already at v${AGENT_BLOCK_VERSION}.`);
306
- }
292
+ const result = syncAgentSkill({});
293
+ printSyncSummary(result);
307
294
 
308
- if (errors.length > 0) return;
309
-
310
- if (blocksPresent === 0 && !existing) return;
295
+ if (!syncChanged(result) && !result.errors.length) {
296
+ console.log(`SmallDocs skill already at v${SKILL_VERSION}.`);
297
+ }
298
+ if (result.errors.length) return;
311
299
 
312
300
  writeSetupState({
313
- setupCompleted: existing?.setupCompleted || new Date().toISOString(),
314
- writtenTo: [...changed, ...current].map(r => r.path),
301
+ setupCompleted: existing && existing.setupCompleted || new Date().toISOString(),
302
+ writtenTo: [canonicalSkillFile(os.homedir())],
315
303
  declined: false,
316
304
  autoRefreshAgentFiles: existing ? existing.autoRefreshAgentFiles !== false : true,
317
- autoInstallUpdates: existing?.autoInstallUpdates ?? false,
305
+ autoInstallUpdates: existing && existing.autoInstallUpdates != null ? existing.autoInstallUpdates : false,
318
306
  lastRunVersion: VERSION,
307
+ ...setupStateFields(null, null, existing),
319
308
  });
320
309
  }
321
310
 
322
- // `sdoc auto-update on|off|status` flips state.autoInstallUpdates.
311
+ // `sdoc auto-update on|off|status` - flips state.autoInstallUpdates.
323
312
  function runAutoUpdateSubcommand(arg) {
324
313
  let state = readSetupState();
325
314
  if (!state) {
@@ -328,12 +317,12 @@ function runAutoUpdateSubcommand(arg) {
328
317
  }
329
318
  if (arg === 'on') {
330
319
  writeSetupState({ ...state, autoInstallUpdates: true });
331
- console.log(' Auto-install of sdoc updates: on');
320
+ console.log('\u2713 Auto-install of sdoc updates: on');
332
321
  return;
333
322
  }
334
323
  if (arg === 'off') {
335
324
  writeSetupState({ ...state, autoInstallUpdates: false });
336
- console.log(' Auto-install of sdoc updates: off');
325
+ console.log('\u2713 Auto-install of sdoc updates: off');
337
326
  return;
338
327
  }
339
328
  console.log(`Auto-install of sdoc updates: ${state.autoInstallUpdates ? 'on' : 'off'}`);
@@ -344,8 +333,10 @@ module.exports = {
344
333
  ask,
345
334
  askAutoInstallConsent,
346
335
  askAutoRefreshConsent,
336
+ dryRunPreview,
347
337
  runSetup,
348
338
  runRefresh,
349
339
  runAutoUpdateSubcommand,
350
340
  maybeAutoRefresh,
341
+ cloudFirstSettings,
351
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
- return `${baseUrl}/s/${id}#${params.toString()}`;
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
  };