@skrr-ai/cli 0.1.66 → 0.1.67

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.
@@ -35,9 +35,6 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.shouldSkipLocalLogout = shouldSkipLocalLogout;
37
37
  const core_1 = require("@oclif/core");
38
- const fs = __importStar(require("node:fs"));
39
- const os = __importStar(require("node:os"));
40
- const path = __importStar(require("node:path"));
41
38
  const base_command_1 = require("../base-command");
42
39
  const config_1 = require("../lib/config");
43
40
  const first_party_harness_managed_1 = require("../lib/first-party-harness-managed");
@@ -112,21 +109,6 @@ function shouldSkipLocalLogout(input) {
112
109
  function platformManagedHandoff() {
113
110
  return (0, dedicated_service_1.runningOnDedicatedRuntimeGuest)() && Boolean((0, daemonBroker_1.findBrokeredHandoffDescriptor)());
114
111
  }
115
- /**
116
- * Whether this computer has a configured skrr background service — the file
117
- * `skrr daemon install` writes, which a logout deliberately does not remove
118
- * (the service's OWN credential is what becomes unreadable; OSK-12483).
119
- * The daemon's profile dir is `~/.skrr/profiles/<name>` for every profile,
120
- * including `default`.
121
- */
122
- function daemonConfigPresent(profile) {
123
- try {
124
- return fs.existsSync(path.join(os.homedir(), '.skrr', 'profiles', profile, 'daemon.json'));
125
- }
126
- catch {
127
- return false;
128
- }
129
- }
130
112
  const SIGN_IN_AGAIN = 'To sign in again, run `skrr login`.';
131
113
  class Logout extends base_command_1.BaseCommand {
132
114
  static description = 'Sign out and clear locally-stored tokens. ' +
@@ -214,39 +196,36 @@ class Logout extends base_command_1.BaseCommand {
214
196
  (0, auth_storage_1.clearAllBackends)();
215
197
  // Sweep the credentials this product wrote under its PREVIOUS brand.
216
198
  //
217
- // Without this, logout removed only the current service and still reported
218
- // an unqualified "Signed out." — leaving the pre-rename CLI credential, the
219
- // pre-rename daemon credentials and BOTH KEKs on the machine, so the sealed
220
- // envelope and the key that unwraps it were both still there. `--all-devices`
221
- // is documented for "a stolen-laptop incident", which is precisely when that
222
- // is the wrong outcome.
223
- //
224
- // Reported rather than silent: this destroys credentials, and one of them
225
- // may belong to a still-installed `oversky` binary.
226
- const sweptLegacy = keychain.deleteLegacyBrandEntries();
227
- // L11/L12 — also wipe shared device-identity + envelope artifacts.
228
- // These files live under ~/.skrr/profiles/default/ and are shared
229
- // with the daemon, so a `skrr logout` here also rotates the daemon's
230
- // identity on the next start. Each call is wrapped so a single
231
- // cleanup failure (permissions, transient fs error) doesn't break
232
- // the local logout — the user already saw "Signed out" intent.
233
- try {
234
- await (0, device_identity_1.resetDeviceIdentity)({ profile: 'default', clearOnDisk: true });
235
- }
236
- catch {
237
- /* best-effort */
238
- }
239
- try {
240
- (0, device_identity_1.clearEnrollmentMarker)('default');
241
- }
242
- catch {
243
- /* best-effort */
244
- }
245
- try {
246
- await (0, cred_envelope_1.resetCredEnvelope)({ profile: 'default', clearOnDisk: true });
247
- }
248
- catch {
249
- /* best-effort */
199
+ // Ordinary logout clears retired CLI slots only. The explicit
200
+ // all-devices operation also clears retired daemon credentials and KEKs.
201
+ // Removing those during a CLI-only logout would break a running daemon's
202
+ // next restart or refresh (OSK-12483).
203
+ const sweptLegacy = keychain.deleteLegacyBrandEntries({
204
+ includeMachineCredentials: flags['all-devices'],
205
+ });
206
+ // Device identity and the wrapped DEK belong to the daemon as well as the
207
+ // CLI. Preserve both for a CLI-only logout. An explicit all-devices logout
208
+ // revokes daemon sessions and may then clear this profile's machine keys.
209
+ if (flags['all-devices']) {
210
+ const profile = keychain.getActiveProfile();
211
+ try {
212
+ await (0, device_identity_1.resetDeviceIdentity)({ profile, clearOnDisk: true });
213
+ }
214
+ catch {
215
+ /* best-effort */
216
+ }
217
+ try {
218
+ (0, device_identity_1.clearEnrollmentMarker)(profile);
219
+ }
220
+ catch {
221
+ /* best-effort */
222
+ }
223
+ try {
224
+ await (0, cred_envelope_1.resetCredEnvelope)({ profile, clearOnDisk: true });
225
+ }
226
+ catch {
227
+ /* best-effort */
228
+ }
250
229
  }
251
230
  // Strip any stray token/refreshToken from cli-config.json too —
252
231
  // `migrateAuthOutOfConfig` should have done this at init time, but
@@ -300,18 +279,8 @@ class Logout extends base_command_1.BaseCommand {
300
279
  }
301
280
  else {
302
281
  this.log(SIGN_IN_AGAIN);
303
- // OSK-12483 — the shared envelope wipe above also orphans THIS machine's
304
- // daemon credential (it is sealed under the DEK just deleted). The
305
- // running daemon keeps working on its in-memory copy, but its next start
306
- // cannot read the stored one and needs `skrr daemon login` — a plain
307
- // `skrr login` mints a cli-scope credential and does not re-enroll it.
308
- // Say that now, while the person is still looking at a screen; otherwise
309
- // the daemon simply never comes back and nothing anywhere names why.
310
- // `daemon.json` is written when the service is installed/configured and
311
- // survives this logout.
312
- if (daemonConfigPresent(keychain.getActiveProfile())) {
313
- this.log("This also signs out this computer's skrr background service — it will need " +
314
- '`skrr daemon login` before it runs again.');
282
+ if (!flags['all-devices']) {
283
+ this.log('The skrr background service keeps its own sign-in and may provide CLI access.');
315
284
  }
316
285
  }
317
286
  if (sweptLegacy.length > 0) {
@@ -13,6 +13,7 @@ class StoreAdmissionsApprove extends base_command_1.BaseCommand {
13
13
  static examples = [
14
14
  '<%= config.bin %> store admissions approve ar_abc123',
15
15
  '<%= config.bin %> store admissions approve skill_abc123 --kind skill',
16
+ '<%= config.bin %> store admissions approve store_release_abc123 --kind artifact',
16
17
  '<%= config.bin %> store admissions approve store_release_abc123 --artifact',
17
18
  ];
18
19
  static args = {
@@ -25,8 +26,8 @@ class StoreAdmissionsApprove extends base_command_1.BaseCommand {
25
26
  static flags = {
26
27
  artifact: core_1.Flags.boolean({ description: 'The id is a Skill or Action artifact release' }),
27
28
  kind: core_1.Flags.string({
28
- description: 'The id is a listing, not a release',
29
- options: ['skill', 'action'],
29
+ description: 'The id is a skill/action listing or a Store artifact release',
30
+ options: ['skill', 'action', 'artifact'],
30
31
  }),
31
32
  note: core_1.Flags.string({ description: 'Decision note' }),
32
33
  json: core_1.Flags.boolean({ description: 'Output as JSON' }),
@@ -35,9 +36,10 @@ class StoreAdmissionsApprove extends base_command_1.BaseCommand {
35
36
  this.requireAuth();
36
37
  const { args, flags } = await this.parse(StoreAdmissionsApprove);
37
38
  try {
38
- if (flags.artifact && flags.kind)
39
- this.error('Use either --artifact or --kind');
40
- const result = flags.artifact
39
+ if (flags.artifact && flags.kind && flags.kind !== 'artifact') {
40
+ this.error('Use either --artifact or a listing --kind');
41
+ }
42
+ const result = flags.artifact || flags.kind === 'artifact'
41
43
  ? await data_provider_1.dataService.approveStoreArtifactAdmission(args.id, flags.note)
42
44
  : flags.kind
43
45
  ? await data_provider_1.dataService.approveStoreListingAdmission(flags.kind, args.id, flags.note)
@@ -9,7 +9,7 @@ const base_command_1 = require("../../../base-command");
9
9
  * not the publisher.
10
10
  */
11
11
  class StoreAdmissionsList extends base_command_1.BaseCommand {
12
- static description = 'List releases and listings awaiting capability-tiered review (store_curation)';
12
+ static description = 'List Agent releases, artifact releases and listings awaiting capability-tiered review (store_curation)';
13
13
  static examples = [
14
14
  '<%= config.bin %> store admissions list',
15
15
  '<%= config.bin %> store admissions list --state refused --json',
@@ -28,8 +28,22 @@ class StoreAdmissionsList extends base_command_1.BaseCommand {
28
28
  const result = await data_provider_1.dataService.getStoreReleaseAdmissions({
29
29
  state: flags.state === 'refused' ? 'refused' : 'pending_review',
30
30
  });
31
+ const artifacts = [...(result.artifacts || [])];
32
+ let after = result.artifactNextCursor;
33
+ const seen = new Set();
34
+ while (after) {
35
+ if (seen.has(after))
36
+ throw new Error('Artifact admission queue returned a repeated cursor');
37
+ seen.add(after);
38
+ const page = await data_provider_1.dataService.getStoreReleaseAdmissions({
39
+ state: flags.state === 'refused' ? 'refused' : 'pending_review',
40
+ artifactAfter: after,
41
+ });
42
+ artifacts.push(...(page.artifacts || []));
43
+ after = page.artifactNextCursor;
44
+ }
31
45
  if (flags.json) {
32
- this.log(JSON.stringify(result, null, 2));
46
+ this.log(JSON.stringify({ ...result, artifacts, artifactNextCursor: null }, null, 2));
33
47
  return;
34
48
  }
35
49
  const rows = [
@@ -45,7 +59,7 @@ class StoreAdmissionsList extends base_command_1.BaseCommand {
45
59
  name: l.name || '',
46
60
  facets: (l.admission?.facets || []).join(','),
47
61
  })),
48
- ...(result.artifacts || []).map((a) => ({
62
+ ...artifacts.map((a) => ({
49
63
  kind: `${a.entityType} artifact`,
50
64
  id: a.releaseId,
51
65
  name: `${a.entityId} v${a.versionLabel}`,
@@ -12,6 +12,7 @@ class StoreAdmissionsRefuse extends base_command_1.BaseCommand {
12
12
  static examples = [
13
13
  '<%= config.bin %> store admissions refuse ar_abc123 --note "unbounded network egress"',
14
14
  '<%= config.bin %> store admissions refuse act_abc123 --kind action --note "reason"',
15
+ '<%= config.bin %> store admissions refuse store_release_abc123 --kind artifact --note "reason"',
15
16
  '<%= config.bin %> store admissions refuse store_release_abc123 --artifact --note "reason"',
16
17
  ];
17
18
  static args = {
@@ -24,8 +25,8 @@ class StoreAdmissionsRefuse extends base_command_1.BaseCommand {
24
25
  static flags = {
25
26
  artifact: core_1.Flags.boolean({ description: 'The id is a Skill or Action artifact release' }),
26
27
  kind: core_1.Flags.string({
27
- description: 'The id is a listing, not a release',
28
- options: ['skill', 'action'],
28
+ description: 'The id is a skill/action listing or a Store artifact release',
29
+ options: ['skill', 'action', 'artifact'],
29
30
  }),
30
31
  note: core_1.Flags.string({ description: 'Why the refusal — required', required: true }),
31
32
  json: core_1.Flags.boolean({ description: 'Output as JSON' }),
@@ -34,9 +35,10 @@ class StoreAdmissionsRefuse extends base_command_1.BaseCommand {
34
35
  this.requireAuth();
35
36
  const { args, flags } = await this.parse(StoreAdmissionsRefuse);
36
37
  try {
37
- if (flags.artifact && flags.kind)
38
- this.error('Use either --artifact or --kind');
39
- const result = flags.artifact
38
+ if (flags.artifact && flags.kind && flags.kind !== 'artifact') {
39
+ this.error('Use either --artifact or a listing --kind');
40
+ }
41
+ const result = flags.artifact || flags.kind === 'artifact'
40
42
  ? await data_provider_1.dataService.refuseStoreArtifactAdmission(args.id, flags.note || '')
41
43
  : flags.kind
42
44
  ? await data_provider_1.dataService.refuseStoreListingAdmission(flags.kind, args.id, flags.note || '')
@@ -60,18 +60,18 @@ class StoreInstall extends base_command_1.BaseCommand {
60
60
  // The server still refuses a second install of a listing you own — this is
61
61
  // the friendly read that says so before a quote or a prompt (OSK-12287:
62
62
  // `installs` is the read model; `purchased` answers membership cheaply).
63
+ let alreadyInstalled = false;
63
64
  try {
64
65
  const owned = await data_provider_1.dataService.getPurchasedAgents();
65
- if ((owned?.purchasedAgentIds ?? []).includes(args.agentId)) {
66
- this.error(`${args.agentId} is already installed. See it: skrr store updates, or update it: skrr store update ${args.agentId}`, { exit: 1 });
67
- }
66
+ alreadyInstalled = (owned?.purchasedAgentIds ?? []).includes(args.agentId);
68
67
  }
69
68
  catch (err) {
70
69
  // A read failure must not block an install — the server re-checks anyway.
71
- if (err.exit)
72
- throw err;
73
70
  this.warn(`Could not check what you own (${err.message}); continuing.`);
74
71
  }
72
+ if (alreadyInstalled) {
73
+ this.error(`${args.agentId} is already installed. See it: skrr store updates, or update it: skrr store update ${args.agentId}`, { exit: 1 });
74
+ }
75
75
  // What installing would charge NOW, resolved by the server the way the
76
76
  // install resolves it (OSK-12240). The install then pins what was shown, so
77
77
  // a listing whose price moved in between is refused rather than charged.
@@ -17,11 +17,10 @@ exports.__resetPrefetchCliAuthForTest = __resetPrefetchCliAuthForTest;
17
17
  * Runs as a side-effect import from base-command.ts so every command path
18
18
  * (including future ones) gets the same DI setup. Idempotent and cheap.
19
19
  *
20
- * Phase A scope:
20
+ * Runtime scope:
21
21
  * - Logger: a thin shim over `console`. CLI is a one-shot process; we
22
22
  * don't ship winston for this.
23
- * - configDir: still ~/.sky/ in Phase A. Phase D will repoint to
24
- * ~/.skrr/ with a one-shot read-shim migration.
23
+ * - configDir: the shared ~/.skrr/ root, with profile state below it.
25
24
  * - Kill switches: respect OVERSKY_DISABLE_REFRESH_SCHEDULER for parity
26
25
  * with the daemon's rollback knob.
27
26
  * - binaryName: 'skrr' so reauth banners say `skrr login` not `oversky login`.
@@ -42,6 +41,7 @@ const auth_core_1 = require("@skrr-ai/auth-core");
42
41
  const cred_envelope_1 = require("./cred-envelope");
43
42
  const device_identity_1 = require("./device-identity");
44
43
  const auth_storage_1 = require("./auth-storage");
44
+ const keychain_1 = require("./keychain");
45
45
  function envFlag(name) {
46
46
  const v = process.env[name];
47
47
  return v === '1' || v === 'true' || v === 'TRUE';
@@ -165,12 +165,6 @@ const cliLogger = {
165
165
  },
166
166
  error: (...args) => console.error('[auth-core error]', ...args),
167
167
  };
168
- /**
169
- * Profile name in shared on-disk paths. Pre-Phase-K every consumer
170
- * resolves to `'default'`; matches `daemon/src/auth.ts:DEFAULT_PROFILE`
171
- * so cross-binary file paths align byte-for-byte.
172
- */
173
- const DEFAULT_PROFILE = 'default';
174
168
  const DEDICATED_AUTH_GENERATION_MARKER = 'dedicated-runtime-auth-generation';
175
169
  /**
176
170
  * A Dedicated Runtime's workspace survives replacement but `/etc/machine-id`
@@ -192,7 +186,11 @@ async function prepareDedicatedRuntimeAuthGeneration(options = {}) {
192
186
  return false;
193
187
  const configDir = options.configDir ?? (0, auth_core_1.resolveConfigRoot)();
194
188
  node_fs_1.default.mkdirSync(configDir, { recursive: true, mode: 0o700 });
195
- const markerPath = node_path_1.default.join(configDir, DEDICATED_AUTH_GENERATION_MARKER);
189
+ const profile = (0, keychain_1.getActiveProfile)();
190
+ const markerName = profile === keychain_1.DEFAULT_PROFILE
191
+ ? DEDICATED_AUTH_GENERATION_MARKER
192
+ : `${DEDICATED_AUTH_GENERATION_MARKER}.${profile}`;
193
+ const markerPath = node_path_1.default.join(configDir, markerName);
196
194
  const release = await proper_lockfile_1.default.lock(configDir, {
197
195
  lockfilePath: `${markerPath}.lock`,
198
196
  realpath: false,
@@ -212,12 +210,12 @@ async function prepareDedicatedRuntimeAuthGeneration(options = {}) {
212
210
  (options.clearStoredAuth ?? auth_storage_1.clearAllBackends)();
213
211
  await (options.clearDeviceIdentity ??
214
212
  (() => (0, device_identity_1.resetDeviceIdentity)({
215
- profile: DEFAULT_PROFILE,
213
+ profile,
216
214
  clearOnDisk: true,
217
215
  })))();
218
216
  await (options.clearCredentialEnvelope ??
219
217
  (() => (0, cred_envelope_1.resetCredEnvelope)({
220
- profile: DEFAULT_PROFILE,
218
+ profile,
221
219
  clearOnDisk: true,
222
220
  })))();
223
221
  const temporary = `${markerPath}.tmp.${process.pid}.${Date.now()}`;
@@ -298,6 +296,7 @@ function ensureAuthCoreConfigured() {
298
296
  let _prefetchPromise = null;
299
297
  let _credEnvelopeInitPromise = null;
300
298
  let _credEnvelopeInitStartedAt = 0;
299
+ let _prefetchProfile = null;
301
300
  class CliCredentialInitializationError extends Error {
302
301
  code = 'CREDENTIAL_INITIALIZATION_UNAVAILABLE';
303
302
  exitCode = 75;
@@ -342,15 +341,21 @@ function encryptedCredentialInitError(timedOut, cause) {
342
341
  'The credential remains protected; unlock or repair the OS credential store and retry.');
343
342
  }
344
343
  function ensureCliAuthPrefetchStarted() {
345
- if (_prefetchPromise)
344
+ const profile = (0, keychain_1.getActiveProfile)();
345
+ if (_prefetchPromise) {
346
+ if (_prefetchProfile !== profile) {
347
+ throw new CliCredentialInitializationError(`Credential initialization already belongs to profile ${_prefetchProfile}; start a new skrr process for profile ${profile}.`);
348
+ }
346
349
  return;
350
+ }
351
+ _prefetchProfile = profile;
347
352
  _credEnvelopeInitStartedAt = Date.now();
348
- const envelopeInit = (0, cred_envelope_1.initCredEnvelope)(DEFAULT_PROFILE);
353
+ const envelopeInit = (0, cred_envelope_1.initCredEnvelope)(profile);
349
354
  _credEnvelopeInitPromise = envelopeInit;
350
355
  _prefetchPromise = (async () => {
351
356
  try {
352
357
  await envelopeInit;
353
- await (0, device_identity_1.initDeviceIdentity)(DEFAULT_PROFILE);
358
+ await (0, device_identity_1.initDeviceIdentity)(profile);
354
359
  // The CLI deliberately does NOT enroll a device key with the server
355
360
  // (OSK-12063). Enrollment pins the FIRST key presented on a machine's
356
361
  // Daemon row, and every later refresh signed by a different key is
@@ -431,6 +436,7 @@ function __resetPrefetchCliAuthForTest() {
431
436
  _prefetchPromise = null;
432
437
  _credEnvelopeInitPromise = null;
433
438
  _credEnvelopeInitStartedAt = 0;
439
+ _prefetchProfile = null;
434
440
  }
435
441
  // Eager-configure on first import. Cheap and avoids ordering pitfalls in
436
442
  // any future command that calls auth-core helpers from outside a Command.
@@ -1,4 +1,4 @@
1
- /** Pre-Phase-K namespace; reserved for a future migration helper. */
1
+ /** Default profile when no profile flag or environment override is present. */
2
2
  export declare const DEFAULT_PROFILE = "default";
3
3
  export declare function setActiveProfile(profile: string): void;
4
4
  /**
@@ -102,11 +102,13 @@ export declare function deleteRefreshToken(): void;
102
102
  * Returns the services it actually removed something from, so the caller can
103
103
  * say what happened instead of destroying credentials silently.
104
104
  *
105
- * NOTE this also signs out the retired `oversky` binary, if one is still
106
- * installed. That is the intent — same account, same machine — and the cutover
107
- * runbook already lists these items for removal.
105
+ * Ordinary logout removes only retired CLI slots. Machine credentials and
106
+ * KEKs are shared with the daemon and are removed only by --all-devices.
107
+ * That broader mode also signs out an installed retired `oversky` daemon.
108
108
  */
109
- export declare function deleteLegacyBrandEntries(): string[];
109
+ export declare function deleteLegacyBrandEntries(options?: {
110
+ includeMachineCredentials?: boolean;
111
+ }): string[];
110
112
  /**
111
113
  * Remove every CLI keychain slot for the active profile. Used by `skrr logout`.
112
114
  * Safe to call on a clean system — missing items are not an error.
@@ -67,10 +67,8 @@ exports.resolveProfileFromArgv = resolveProfileFromArgv;
67
67
  * Mirrors `daemon/src/keychain.ts` but simpler:
68
68
  * - No prefetch: the CLI is a short-lived foreground process; parallelizing
69
69
  * the keychain probe with other startup work isn't worth the code.
70
- * - Namespaced `ai.skrr.cli.<profile>` from day one. Before Phase K
71
- * ships profile switching, `<profile>` is always `default`, but baking
72
- * the suffix in now makes the future migration a rename instead of a
73
- * schema change.
70
+ * - Namespaced `ai.skrr.cli.<profile>` so each selected profile keeps
71
+ * its own CLI refresh family.
74
72
  * - Distinct service from the daemon (`ai.skrr.cli.*` vs.
75
73
  * `ai.skrr.daemon.*`). The CLI holds `scope: 'cli'` tokens; the
76
74
  * daemon holds `scope: 'daemon'`. They must never read each other's
@@ -121,7 +119,7 @@ const cred_envelope_1 = require("./cred-envelope");
121
119
  const auth_core_1 = require("@skrr-ai/auth-core");
122
120
  /** Service-name prefix. Always followed by `.<profile>`. */
123
121
  const SERVICE_BASE = 'ai.skrr.cli';
124
- /** Pre-Phase-K namespace; reserved for a future migration helper. */
122
+ /** Default profile when no profile flag or environment override is present. */
125
123
  exports.DEFAULT_PROFILE = 'default';
126
124
  const ACCOUNT_TOKEN = 'cli-token';
127
125
  const ACCOUNT_EXPIRES = 'cli-token-expires';
@@ -619,14 +617,16 @@ function deleteEntireService(service) {
619
617
  * Returns the services it actually removed something from, so the caller can
620
618
  * say what happened instead of destroying credentials silently.
621
619
  *
622
- * NOTE this also signs out the retired `oversky` binary, if one is still
623
- * installed. That is the intent — same account, same machine — and the cutover
624
- * runbook already lists these items for removal.
620
+ * Ordinary logout removes only retired CLI slots. Machine credentials and
621
+ * KEKs are shared with the daemon and are removed only by --all-devices.
622
+ * That broader mode also signs out an installed retired `oversky` daemon.
625
623
  */
626
- function deleteLegacyBrandEntries() {
624
+ function deleteLegacyBrandEntries(options = {}) {
627
625
  if (!isAvailable())
628
626
  return [];
629
- return discoverLegacyBrandServices().filter((service) => deleteEntireService(service));
627
+ return discoverLegacyBrandServices()
628
+ .filter((service) => options.includeMachineCredentials === true || /^(?:ai|com)\.oversky\.cli\./.test(service))
629
+ .filter((service) => deleteEntireService(service));
630
630
  }
631
631
  /**
632
632
  * Remove every CLI keychain slot for the active profile. Used by `skrr logout`.
@@ -45,6 +45,8 @@ export interface EnsureFreshCliCredentialResult {
45
45
  /** True when we adopted a fresher token another process already wrote. */
46
46
  adoptedStoredToken?: boolean;
47
47
  pair?: TokenPair;
48
+ /** Stable diagnostic code; never contains token or server response text. */
49
+ refreshFailureCode?: string;
48
50
  skippedReason?: 'no_token' | 'ci_token' | 'external_source' | 'env_override' | 'bare_mode' | 'fresh' | 'no_refresh_token' | 'refresh_failed';
49
51
  }
50
52
  /**
@@ -58,6 +58,7 @@ class CliRefreshUnavailableError extends Error {
58
58
  }
59
59
  exports.CliRefreshUnavailableError = CliRefreshUnavailableError;
60
60
  let ensureInFlight = null;
61
+ let reportedPermanentRefreshFailure = false;
61
62
  function expiryFromToken(token) {
62
63
  if (!token)
63
64
  return undefined;
@@ -158,8 +159,25 @@ async function ensureFreshCliCredential(opts = {}) {
158
159
  pair,
159
160
  };
160
161
  }
161
- catch {
162
- return { credential, refreshed: false, skippedReason: 'refresh_failed' };
162
+ catch (err) {
163
+ const refreshFailureCode = err instanceof auth_core_1.PermanentAuthFailure
164
+ ? err.code
165
+ : err instanceof auth_core_1.TransientAuthFailure
166
+ ? 'TRANSIENT_REFRESH_FAILURE'
167
+ : err instanceof CliRefreshUnavailableError
168
+ ? 'LOCAL_REFRESH_UNAVAILABLE'
169
+ : 'LOCAL_REFRESH_ERROR';
170
+ if (err instanceof auth_core_1.PermanentAuthFailure && !reportedPermanentRefreshFailure) {
171
+ reportedPermanentRefreshFailure = true;
172
+ console.error(`[skrr/auth] Stored CLI session refresh was rejected (${refreshFailureCode}). ` +
173
+ 'The background service may still provide access; run `skrr login` if it cannot.');
174
+ }
175
+ return {
176
+ credential,
177
+ refreshed: false,
178
+ skippedReason: 'refresh_failed',
179
+ refreshFailureCode,
180
+ };
163
181
  }
164
182
  finally {
165
183
  ensureInFlight = null;
@@ -10,7 +10,7 @@ type DeviceIdentityState = {
10
10
  profile: string;
11
11
  envelopeProtected: boolean;
12
12
  };
13
- type DisabledReason = 'init_failed' | 'load_failed' | 'persist_failed';
13
+ type DisabledReason = 'init_failed' | 'load_failed' | 'persist_failed' | 'unreadable_key';
14
14
  /** @internal Test seam — force-set state. */
15
15
  export declare function __setStateForTest(next: DeviceIdentityState): DeviceIdentityState;
16
16
  /** @internal Test seam — read state. */
@@ -42,11 +42,13 @@ export declare function describeDeviceKeySplit(profile: string): {
42
42
  * 2. Generate fresh keypair and persist. Failure to persist is a hard
43
43
  * error landing in disabled+persist_failed.
44
44
  */
45
- export declare function initDeviceIdentity(profile: string): Promise<void>;
45
+ export declare function initDeviceIdentity(profile: string, options?: {
46
+ recoverUnreadable?: boolean;
47
+ }): Promise<void>;
46
48
  /**
47
- * Reset state. With `clearOnDisk=true` deletes both keypair files —
48
- * used by `oversky logout` / `sky logout` so a freshly-logged-in user
49
- * gets a fresh device identity that the daemon will see on its next start.
49
+ * Reset in-memory state. With `clearOnDisk=true` deletes both keypair files.
50
+ * Machine-wide sign-out may use that option; ordinary CLI sign-out preserves
51
+ * the daemon's pinned identity.
50
52
  */
51
53
  export declare function resetDeviceIdentity(opts?: {
52
54
  profile?: string;
@@ -100,6 +100,26 @@ function publicKeyPath(profile) {
100
100
  function privateKeyPath(profile) {
101
101
  return node_path_1.default.join(profileConfigDir(profile), PRIVATE_KEY_FILENAME);
102
102
  }
103
+ function storedKeyMaterialExists(profile) {
104
+ let files;
105
+ try {
106
+ files = [publicKeyPath(profile), privateKeyPath(profile), enrollmentMarkerPath(profile)];
107
+ }
108
+ catch {
109
+ return true;
110
+ }
111
+ for (const file of files) {
112
+ try {
113
+ node_fs_1.default.lstatSync(file);
114
+ return true;
115
+ }
116
+ catch (err) {
117
+ if (err.code !== 'ENOENT')
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
103
123
  function enrollmentMarkerPath(profile) {
104
124
  return node_path_1.default.join(profileConfigDir(profile), ENROLLMENT_MARKER_FILENAME);
105
125
  }
@@ -339,7 +359,7 @@ async function consolidateDeviceIdentity(profile) {
339
359
  * 2. Generate fresh keypair and persist. Failure to persist is a hard
340
360
  * error landing in disabled+persist_failed.
341
361
  */
342
- async function initDeviceIdentity(profile) {
362
+ async function initDeviceIdentity(profile, options = {}) {
343
363
  if (_state.kind !== 'uninit')
344
364
  return;
345
365
  if (typeof profile !== 'string' || profile.length === 0) {
@@ -374,6 +394,18 @@ async function initDeviceIdentity(profile) {
374
394
  });
375
395
  return;
376
396
  }
397
+ // A first install has neither file. If either file already exists, a null
398
+ // load is a lost, corrupt or temporarily unreadable identity. Replacing it
399
+ // during ordinary boot would make the next refresh sign with a key the
400
+ // server has never pinned, turning a local read failure into an unexpected
401
+ // DEVICE_KEY_MISMATCH. Only an explicit interactive login may replace it;
402
+ // that login proves the new key and re-pins it before issuing credentials.
403
+ const hasStoredKey = storedKeyMaterialExists(profile);
404
+ if (hasStoredKey && !options.recoverUnreadable) {
405
+ _state = { kind: 'disabled', reason: 'unreadable_key' };
406
+ emit('recovery_required', false, { profile, reason: 'stored_key_unreadable' });
407
+ return;
408
+ }
377
409
  let fresh;
378
410
  try {
379
411
  fresh = (0, deviceKey_js_1.generateDeviceKeyPair)();
@@ -413,9 +445,9 @@ async function initDeviceIdentity(profile) {
413
445
  });
414
446
  }
415
447
  /**
416
- * Reset state. With `clearOnDisk=true` deletes both keypair files —
417
- * used by `oversky logout` / `sky logout` so a freshly-logged-in user
418
- * gets a fresh device identity that the daemon will see on its next start.
448
+ * Reset in-memory state. With `clearOnDisk=true` deletes both keypair files.
449
+ * Machine-wide sign-out may use that option; ordinary CLI sign-out preserves
450
+ * the daemon's pinned identity.
419
451
  */
420
452
  async function resetDeviceIdentity(opts) {
421
453
  if (opts?.clearOnDisk && opts.profile) {
@@ -10,7 +10,7 @@ type DeviceIdentityState = {
10
10
  profile: string;
11
11
  envelopeProtected: boolean;
12
12
  };
13
- type DisabledReason = 'init_failed' | 'load_failed' | 'persist_failed';
13
+ type DisabledReason = 'init_failed' | 'load_failed' | 'persist_failed' | 'unreadable_key';
14
14
  /** @internal Test seam — force-set state. */
15
15
  export declare function __setStateForTest(next: DeviceIdentityState): DeviceIdentityState;
16
16
  /** @internal Test seam — read state. */
@@ -42,11 +42,13 @@ export declare function describeDeviceKeySplit(profile: string): {
42
42
  * 2. Generate fresh keypair and persist. Failure to persist is a hard
43
43
  * error landing in disabled+persist_failed.
44
44
  */
45
- export declare function initDeviceIdentity(profile: string): Promise<void>;
45
+ export declare function initDeviceIdentity(profile: string, options?: {
46
+ recoverUnreadable?: boolean;
47
+ }): Promise<void>;
46
48
  /**
47
- * Reset state. With `clearOnDisk=true` deletes both keypair files —
48
- * used by `oversky logout` / `sky logout` so a freshly-logged-in user
49
- * gets a fresh device identity that the daemon will see on its next start.
49
+ * Reset in-memory state. With `clearOnDisk=true` deletes both keypair files.
50
+ * Machine-wide sign-out may use that option; ordinary CLI sign-out preserves
51
+ * the daemon's pinned identity.
50
52
  */
51
53
  export declare function resetDeviceIdentity(opts?: {
52
54
  profile?: string;