sandoichi 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandoichi",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Reduce repeated tool-output context in Claude Code and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "yuzushi",
@@ -8,10 +8,10 @@ import readline from 'node:readline/promises';
8
8
  import { pathToFileURL } from 'node:url';
9
9
 
10
10
  import {
11
- CONSENT_VERSION, defaultTelemetryConfigPath, enableTelemetry, isDoNotTrack, readTelemetryConfig, TELEMETRY_DETAILS_URL,
11
+ defaultTelemetryConfigPath, enableTelemetry, isDoNotTrack, readTelemetryConfig, TELEMETRY_DETAILS_URL,
12
12
  } from './telemetry.mjs';
13
13
 
14
- const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/N] `;
14
+ const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/yes/N/no] `;
15
15
 
16
16
  export async function runPostinstall({
17
17
  env = process.env, stdin = process.stdin, stdout = process.stdout,
@@ -24,7 +24,7 @@ export async function runPostinstall({
24
24
 
25
25
  const configPath = defaultTelemetryConfigPath(env);
26
26
  const current = readTelemetryConfig(configPath);
27
- if (current.prompted_consent_version >= CONSENT_VERSION) return; // never re-ask on reinstall/upgrade
27
+ if (current.consent_state !== 'unasked') return; // never re-ask on reinstall/upgrade
28
28
 
29
29
  const rl = readlineFactory();
30
30
  let answer;
@@ -1,7 +1,8 @@
1
1
  import path from 'node:path';
2
2
 
3
3
  import {
4
- closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, isDoNotTrack, readTelemetryConfig, TELEMETRY_DETAILS_URL,
4
+ closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, isDoNotTrack, markTelemetryAsked,
5
+ readTelemetryConfig, TELEMETRY_DETAILS_URL,
5
6
  } from './telemetry.mjs';
6
7
  import { PLUGIN_VERSION } from './version.mjs';
7
8
 
@@ -10,28 +11,35 @@ export function runSessionStart({
10
11
  stdout = process.stdout,
11
12
  rootEnv = 'PLUGIN_ROOT',
12
13
  spawnImpl,
13
- configPath = defaultTelemetryConfigPath(env),
14
- statePaths = defaultTelemetryStatePaths(env),
14
+ configPath,
15
+ statePaths,
15
16
  } = {}) {
16
17
  try {
17
- const config = readTelemetryConfig(configPath);
18
- if (config.enabled && !isDoNotTrack(env)) {
18
+ if (isDoNotTrack(env)) {
19
+ stdout.write('{}\n');
20
+ return;
21
+ }
22
+ const telemetryConfigPath = configPath ?? defaultTelemetryConfigPath(env);
23
+ const telemetryStatePaths = statePaths ?? defaultTelemetryStatePaths(env);
24
+ const config = readTelemetryConfig(telemetryConfigPath);
25
+ if (config.enabled) {
19
26
  closeFinishedDays({
20
- statePaths, configPath, day: new Date().toISOString().slice(0, 10),
27
+ statePaths: telemetryStatePaths, configPath: telemetryConfigPath, day: new Date().toISOString().slice(0, 10),
21
28
  pluginVersion: PLUGIN_VERSION, ...(spawnImpl ? { spawnImpl } : {}),
22
29
  });
23
30
  }
24
- if (isDoNotTrack(env) || config.prompted_consent_version > 0) {
31
+ if (config.consent_state !== 'unasked' || !markTelemetryAsked(telemetryConfigPath)) {
25
32
  stdout.write('{}\n');
26
33
  return;
27
34
  }
28
35
  const pluginRoot = env[rootEnv] || path.resolve(import.meta.dirname, '..');
29
36
  const cli = path.join(pluginRoot, 'lib', 'telemetry-cli.mjs');
30
37
  stdout.write(`${JSON.stringify({
38
+ systemMessage: 'Sando can send anonymous aggregate telemetry (opt-in, off by default). '
39
+ + 'Reply with exactly `sando telemetry yes` or `sando telemetry no`, '
40
+ + `or run \`node "${cli}" enable\`. Details: ${TELEMETRY_DETAILS_URL}`,
31
41
  hookSpecificOutput: {
32
42
  hookEventName: 'SessionStart',
33
- systemMessage: 'Sando can send anonymous aggregate telemetry (opt-in, off by default). '
34
- + `Run \`node "${cli}" enable\` to turn it on. Details: ${TELEMETRY_DETAILS_URL}`,
35
43
  },
36
44
  })}\n`);
37
45
  } catch {
@@ -10,7 +10,7 @@ import {
10
10
  } from './telemetry.mjs';
11
11
 
12
12
  const USAGE = 'Usage: sando telemetry <status|enable|disable [--purge]|preview|flush>\n';
13
- const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/N] `;
13
+ const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/yes/N/no] `;
14
14
 
15
15
  async function defaultPrompt(message) {
16
16
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -47,10 +47,13 @@ export async function runTelemetryCli({
47
47
  stdout.write('telemetry already enabled.\n');
48
48
  return current;
49
49
  }
50
+ if (current.consent_state === 'declined') {
51
+ stdout.write('telemetry not enabled.\n');
52
+ return current;
53
+ }
50
54
  const answer = await prompt(CONSENT_PROMPT);
51
55
  const result = enableTelemetry({
52
- configPath, interactive: true,
53
- answer: /^(y|yes)$/i.test((answer ?? '').trim()) ? 'yes' : answer,
56
+ configPath, interactive: true, answer,
54
57
  });
55
58
  stdout.write(result.enabled ? 'telemetry enabled.\n' : 'telemetry not enabled.\n');
56
59
  return result;
package/src/telemetry.mjs CHANGED
@@ -105,6 +105,7 @@ export function serializeEvent(payload) {
105
105
  export const TELEMETRY_CONFIG_VERSION = 1;
106
106
  export const CONSENT_VERSION = 1;
107
107
  export const TELEMETRY_DETAILS_URL = 'https://github.com/yuzushi-dev/Sando/blob/main/TELEMETRY.md';
108
+ export const CONSENT_STATES = ['unasked', 'asked', 'enabled', 'declined'];
108
109
  // Canary phase: shared backend, fronted by a Cloudflare Tunnel so it's
109
110
  // reachable from any of the owner's machines (see
110
111
  // session-handoff/deploy/telemetry/). Rate-limited at nginx (30 req/min/IP).
@@ -119,12 +120,15 @@ export function isDoNotTrack(env = process.env) {
119
120
  function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
120
121
 
121
122
  function emptyTelemetryConfig() {
122
- return { schema_version: TELEMETRY_CONFIG_VERSION, enabled: false, prompted_consent_version: 0 };
123
+ return {
124
+ schema_version: TELEMETRY_CONFIG_VERSION, enabled: false, prompted_consent_version: 0, consent_state: 'unasked',
125
+ };
123
126
  }
124
127
 
125
128
  function validateTelemetryConfig(value) {
126
129
  if (!record(value) || value.schema_version !== TELEMETRY_CONFIG_VERSION || typeof value.enabled !== 'boolean'
127
- || !Number.isInteger(value.prompted_consent_version) || value.prompted_consent_version < 0) {
130
+ || !Number.isInteger(value.prompted_consent_version) || value.prompted_consent_version < 0
131
+ || !CONSENT_STATES.includes(value.consent_state)) {
128
132
  throw new Error('telemetry config is invalid');
129
133
  }
130
134
  if (value.enabled) {
@@ -150,7 +154,14 @@ export function defaultTelemetryStatePaths(env = process.env) {
150
154
 
151
155
  export function readTelemetryConfig(configPath = defaultTelemetryConfigPath()) {
152
156
  if (!fs.existsSync(configPath)) return emptyTelemetryConfig();
153
- return validateTelemetryConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
157
+ const value = JSON.parse(fs.readFileSync(configPath, 'utf8'));
158
+ // Legacy files cannot distinguish an explicit no from blank input or the old
159
+ // y-as-no bug. Keep that ambiguous decision as asked, not as a decline.
160
+ const migrated = Object.hasOwn(value, 'consent_state') ? value : {
161
+ ...value,
162
+ consent_state: value.enabled ? 'enabled' : value.prompted_consent_version > 0 ? 'asked' : 'unasked',
163
+ };
164
+ return validateTelemetryConfig(migrated);
154
165
  }
155
166
 
156
167
  function writeTelemetryConfig(configPath, config) {
@@ -164,19 +175,48 @@ export function statusTelemetry(configPath = defaultTelemetryConfigPath()) {
164
175
  return readTelemetryConfig(configPath);
165
176
  }
166
177
 
167
- /** Only an explicit `yes` in an interactive session enables collection. */
178
+ export function normalizeConsentAnswer(answer) {
179
+ if (typeof answer !== 'string') return undefined;
180
+ const normalized = answer.trim().toLowerCase();
181
+ if (normalized === 'y' || normalized === 'yes') return 'yes';
182
+ if (normalized === 'n' || normalized === 'no') return 'no';
183
+ return undefined;
184
+ }
185
+
186
+ export function markTelemetryAsked(configPath = defaultTelemetryConfigPath()) {
187
+ ensureDirectory(path.dirname(configPath));
188
+ return withLock(`${configPath}.lock`, () => {
189
+ const current = readTelemetryConfig(configPath);
190
+ if (current.consent_state !== 'unasked') return false;
191
+ const next = {
192
+ ...current, enabled: false, prompted_consent_version: CONSENT_VERSION, consent_state: 'asked',
193
+ };
194
+ validateTelemetryConfig(next);
195
+ atomicWrite(configPath, next);
196
+ return true;
197
+ });
198
+ }
199
+
200
+ /** Only an explicit yes enables collection; explicit no declines, other input stays asked/off. */
168
201
  export function enableTelemetry({ configPath = defaultTelemetryConfigPath(), answer, interactive = true, now = () => new Date() } = {}) {
169
202
  if (!interactive) return { ...readTelemetryConfig(configPath), exitCode: 1 };
170
- if (typeof answer !== 'string' || answer.trim().toLowerCase() !== 'yes') {
171
- return writeTelemetryConfig(configPath, { schema_version: TELEMETRY_CONFIG_VERSION, enabled: false, prompted_consent_version: CONSENT_VERSION });
203
+ const normalized = normalizeConsentAnswer(answer);
204
+ if (normalized === 'yes') {
205
+ return writeTelemetryConfig(configPath, {
206
+ schema_version: TELEMETRY_CONFIG_VERSION,
207
+ enabled: true,
208
+ prompted_consent_version: CONSENT_VERSION,
209
+ consent_state: 'enabled',
210
+ consent_version: CONSENT_VERSION,
211
+ consented_at: now().toISOString(),
212
+ endpoint: TELEMETRY_ENDPOINT,
213
+ });
172
214
  }
173
215
  return writeTelemetryConfig(configPath, {
174
216
  schema_version: TELEMETRY_CONFIG_VERSION,
175
- enabled: true,
217
+ enabled: false,
176
218
  prompted_consent_version: CONSENT_VERSION,
177
- consent_version: CONSENT_VERSION,
178
- consented_at: now().toISOString(),
179
- endpoint: TELEMETRY_ENDPOINT,
219
+ consent_state: normalized === 'no' ? 'declined' : 'asked',
180
220
  });
181
221
  }
182
222
 
@@ -509,6 +549,7 @@ export function disableTelemetry({ configPath = defaultTelemetryConfigPath(), pu
509
549
  schema_version: TELEMETRY_CONFIG_VERSION,
510
550
  enabled: false,
511
551
  prompted_consent_version: previous.prompted_consent_version || CONSENT_VERSION,
552
+ consent_state: 'declined',
512
553
  });
513
554
  if (purge) {
514
555
  for (const target of [statePaths.counters, statePaths.queue]) fs.rmSync(target, { force: true });
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ import {
8
+ defaultTelemetryConfigPath, enableTelemetry, isDoNotTrack, readTelemetryConfig,
9
+ } from './telemetry.mjs';
10
+
11
+ const CONSENT_COMMANDS = new Map([
12
+ ['sando telemetry yes', 'yes'],
13
+ ['sando telemetry no', 'no'],
14
+ ]);
15
+
16
+ function pass(stdout) { stdout.write('{}\n'); }
17
+
18
+ export function runUserPromptSubmit({
19
+ env = process.env, input, stdout = process.stdout, configPath,
20
+ } = {}) {
21
+ try {
22
+ if (isDoNotTrack(env)) {
23
+ pass(stdout);
24
+ return;
25
+ }
26
+ const telemetryConfigPath = configPath ?? defaultTelemetryConfigPath(env);
27
+ const rawInput = input === undefined ? fs.readFileSync(0, 'utf8') : input;
28
+ const prompt = JSON.parse(rawInput || '{}').prompt;
29
+ // Normalizza solo gli spazi ai bordi: non introduce ambiguita' (la stringa
30
+ // resta esatta) ed evita di perdere risposte genuine incollate con spazi.
31
+ const answer = typeof prompt === 'string' ? CONSENT_COMMANDS.get(prompt.trim()) : undefined;
32
+ if (!answer) {
33
+ pass(stdout);
34
+ return;
35
+ }
36
+ const current = readTelemetryConfig(telemetryConfigPath);
37
+ if (current.consent_state === 'declined' && answer === 'yes') {
38
+ pass(stdout);
39
+ return;
40
+ }
41
+ const result = enableTelemetry({ configPath: telemetryConfigPath, interactive: true, answer });
42
+ stdout.write(`${JSON.stringify({
43
+ systemMessage: result.enabled ? 'Sando telemetry enabled.' : 'Sando telemetry disabled.',
44
+ })}\n`);
45
+ } catch {
46
+ pass(stdout);
47
+ }
48
+ }
49
+
50
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
51
+ runUserPromptSubmit();
52
+ }
package/src/version.mjs CHANGED
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
4
  const VERSION_PATTERN = /^\d+\.\d+(?:\.\d+)?$/;
5
- const STANDALONE_VERSION = '0.2.0';
5
+ const STANDALONE_VERSION = '0.3.0';
6
6
  const METADATA_FILES = ['package.json', '.claude-plugin/plugin.json', '.codex-plugin/plugin.json'];
7
7
 
8
8
  function findMetadataFile() {