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 +1 -1
- package/src/postinstall.mjs +3 -3
- package/src/session-start.mjs +17 -9
- package/src/telemetry-cli.mjs +6 -3
- package/src/telemetry.mjs +51 -10
- package/src/user-prompt-submit.mjs +52 -0
- package/src/version.mjs +1 -1
package/package.json
CHANGED
package/src/postinstall.mjs
CHANGED
|
@@ -8,10 +8,10 @@ import readline from 'node:readline/promises';
|
|
|
8
8
|
import { pathToFileURL } from 'node:url';
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
-
|
|
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.
|
|
27
|
+
if (current.consent_state !== 'unasked') return; // never re-ask on reinstall/upgrade
|
|
28
28
|
|
|
29
29
|
const rl = readlineFactory();
|
|
30
30
|
let answer;
|
package/src/session-start.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, isDoNotTrack,
|
|
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
|
|
14
|
-
statePaths
|
|
14
|
+
configPath,
|
|
15
|
+
statePaths,
|
|
15
16
|
} = {}) {
|
|
16
17
|
try {
|
|
17
|
-
|
|
18
|
-
|
|
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 (
|
|
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 {
|
package/src/telemetry-cli.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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:
|
|
217
|
+
enabled: false,
|
|
176
218
|
prompted_consent_version: CONSENT_VERSION,
|
|
177
|
-
|
|
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.
|
|
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() {
|