@vectorplane/ctrl-cli 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -14,6 +14,7 @@ npm install -g @vectorplane/ctrl-cli
14
14
 
15
15
  ```bash
16
16
  vp login
17
+ vp logout
17
18
  vp status
18
19
  vp sync
19
20
  ```
@@ -25,8 +26,14 @@ vp sync
25
26
  - abre o navegador para autenticação
26
27
  - conclui o login com callback local seguro
27
28
  - salva a sessão localmente
29
+ - suporta `--no-browser` e `--manual`
28
30
  - em ambientes remotos ou isolados, o loopback local pode exigir um fluxo alternativo
29
31
 
32
+ ### `vp logout`
33
+
34
+ - revoga a sessão do CLI quando possível
35
+ - remove a sessão local do dispositivo
36
+
30
37
  ### `vp sync`
31
38
 
32
39
  - coleta contexto local do workspace
@@ -46,6 +53,7 @@ vp sync
46
53
  - executa verificações locais e de conectividade
47
54
  - `vp config`
48
55
  - gerencia perfis e configuração local
56
+ - inclui `vp config privacy` e `vp config policy`
49
57
  - `vp workspace`
50
58
  - gerencia o workspace atual
51
59
  - `vp session`
@@ -72,9 +80,6 @@ vp sync
72
80
  - tokens nunca são impressos no terminal
73
81
  - tokens nunca são enviados por query string
74
82
  - o callback valida o `state`
75
- - a sessão fica no diretório do usuário
76
-
77
- ## Notas de fluxo
78
-
79
- O plano de endurecimento do login local está em [docs/cli-auth-hardening-phases.md](/home/developer/Documentos/Projetos/vectorplane-ctrl-cli/docs/cli-auth-hardening-phases.md).
80
- O desenho multicanal atual do login está em [docs/cli-login-multichannel.md](/home/developer/Documentos/Projetos/vectorplane-ctrl-cli/docs/cli-login-multichannel.md).
83
+ - a sessão usa secure storage do sistema quando disponível
84
+ - existe fallback para arquivo local apenas quando permitido
85
+ - `vp logout` encerra a sessão local e tenta revogação remota
@@ -13,6 +13,8 @@ function setProfileField(profile, key, value) {
13
13
  return { ...profile, orgId: value };
14
14
  case "environment":
15
15
  return { ...profile, environment: value };
16
+ case "machinePrivacyProfile":
17
+ return { ...profile, machinePrivacyProfile: value };
16
18
  default:
17
19
  throw new ValidationError(`Campo de perfil não suportado: ${key}`);
18
20
  }
@@ -53,6 +55,53 @@ export async function runConfigCommand(args) {
53
55
  return 0;
54
56
  }
55
57
  }
58
+ if (subcommand === "privacy") {
59
+ const action = rest[0] ?? "get";
60
+ const active = getActiveProfile(config);
61
+ if (action === "get") {
62
+ process.stdout.write(`${active.machinePrivacyProfile}\n`);
63
+ return 0;
64
+ }
65
+ if (action === "set") {
66
+ const value = rest[1];
67
+ if (!value) {
68
+ throw new ValidationError("Uso: vp config privacy set <standard|minimal|enterprise>");
69
+ }
70
+ const profileName = getStringOption(parsed, "profile") ?? config.activeProfile;
71
+ const current = config.profiles[profileName];
72
+ if (!current) {
73
+ throw new ValidationError(`Perfil ${profileName} não existe.`);
74
+ }
75
+ await upsertProfile(profileName, { ...current, machinePrivacyProfile: value });
76
+ process.stdout.write(`Privacidade da máquina atualizada: ${value}\n`);
77
+ return 0;
78
+ }
79
+ }
80
+ if (subcommand === "policy") {
81
+ const action = rest[0] ?? "list";
82
+ if (action === "list") {
83
+ process.stdout.write(JSON.stringify(config.policy, null, 2));
84
+ process.stdout.write("\n");
85
+ return 0;
86
+ }
87
+ if (action === "set") {
88
+ const key = rest[1];
89
+ const value = rest[2];
90
+ if (!key || value === undefined) {
91
+ throw new ValidationError("Uso: vp config policy set <chave> <true|false>");
92
+ }
93
+ const next = {
94
+ ...config,
95
+ policy: {
96
+ ...config.policy,
97
+ [key]: ["1", "true", "yes", "on"].includes(value.toLowerCase()),
98
+ },
99
+ };
100
+ await saveConfig(next);
101
+ process.stdout.write(`Policy atualizada: ${key}\n`);
102
+ return 0;
103
+ }
104
+ }
56
105
  if (subcommand === "get") {
57
106
  const key = requirePositional({ ...parsed, positionals: rest }, 0, "Informe a chave a ser lida.");
58
107
  const active = getActiveProfile(config);
@@ -63,8 +112,17 @@ export async function runConfigCommand(args) {
63
112
  case "telemetryEnabled":
64
113
  process.stdout.write(String(config.telemetryEnabled));
65
114
  break;
115
+ case "preferredSessionStorage":
116
+ process.stdout.write(String(config.preferredSessionStorage));
117
+ break;
66
118
  default:
67
- process.stdout.write(String(active[key] ?? ""));
119
+ if (key.startsWith("policy.")) {
120
+ const policyKey = key.slice("policy.".length);
121
+ process.stdout.write(String(config.policy[policyKey] ?? ""));
122
+ }
123
+ else {
124
+ process.stdout.write(String(active[key] ?? ""));
125
+ }
68
126
  break;
69
127
  }
70
128
  process.stdout.write("\n");
@@ -82,6 +140,25 @@ export async function runConfigCommand(args) {
82
140
  process.stdout.write(`Configuração atualizada: ${key}\n`);
83
141
  return 0;
84
142
  }
143
+ if (key === "preferredSessionStorage") {
144
+ const next = { ...config, preferredSessionStorage: value };
145
+ await saveConfig(next);
146
+ process.stdout.write(`Configuração atualizada: ${key}\n`);
147
+ return 0;
148
+ }
149
+ if (key.startsWith("policy.")) {
150
+ const policyKey = key.slice("policy.".length);
151
+ const next = {
152
+ ...config,
153
+ policy: {
154
+ ...config.policy,
155
+ [policyKey]: ["1", "true", "yes", "on"].includes(value.toLowerCase()),
156
+ },
157
+ };
158
+ await saveConfig(next);
159
+ process.stdout.write(`Configuração atualizada: ${key}\n`);
160
+ return 0;
161
+ }
85
162
  const profileName = getStringOption(parsed, "profile") ?? config.activeProfile;
86
163
  const current = config.profiles[profileName];
87
164
  if (!current) {
@@ -1,6 +1,6 @@
1
1
  import { access } from "node:fs/promises";
2
2
  import { constants as fsConstants } from "node:fs";
3
- import { configDirectoryExists, ensureSessionAvailable, getConfigDirectoryPath } from "../core/config.js";
3
+ import { configDirectoryExists, ensureSessionAvailable, getConfigDirectoryPath, getSessionStorageInfo } from "../core/config.js";
4
4
  import { collectGitContext } from "../core/git.js";
5
5
  import { collectMachineContext, collectRuntimeContext } from "../core/machine.js";
6
6
  import { loadRuntimeStatus } from "../core/runtime.js";
@@ -18,6 +18,22 @@ async function writable(filePath) {
18
18
  function printCheck(label, ok, details) {
19
19
  process.stdout.write(`${ok ? "OK" : "FAIL"} ${label}: ${details}\n`);
20
20
  }
21
+ function detectExecutionRisks() {
22
+ const risks = [];
23
+ if (process.env.WSL_DISTRO_NAME) {
24
+ risks.push("wsl");
25
+ }
26
+ if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT) {
27
+ risks.push("ssh");
28
+ }
29
+ if (process.env.CI === "true") {
30
+ risks.push("ci");
31
+ }
32
+ if (process.env.CONTAINER || process.env.DOCKER_CONTAINER) {
33
+ risks.push("container");
34
+ }
35
+ return risks;
36
+ }
21
37
  export async function runDoctorCommand(cliVersion) {
22
38
  const runtime = await loadRuntimeStatus();
23
39
  const configDirectory = await getConfigDirectoryPath();
@@ -26,10 +42,16 @@ export async function runDoctorCommand(cliVersion) {
26
42
  collectGitContext(process.cwd()),
27
43
  writable(configDirectory),
28
44
  ]);
45
+ const storage = await getSessionStorageInfo(runtime.profile.name);
46
+ const risks = detectExecutionRisks();
29
47
  printCheck("config_dir", hasConfigDir, configDirectory);
30
48
  printCheck("config_dir_writable", canWriteConfig, canWriteConfig ? "gravável" : "sem permissão de escrita");
31
49
  printCheck("git", git.isRepository, git.isRepository ? (git.rootPath ?? process.cwd()) : "repositório não detectado");
32
50
  printCheck("node", true, process.version);
51
+ printCheck("privacy_profile", true, runtime.profile.machinePrivacyProfile);
52
+ printCheck("session_storage", (storage?.protected ?? false) || runtime.config.preferredSessionStorage === "system", storage ? `${storage.backend}:${storage.protected ? "protected" : "file"}` : runtime.config.preferredSessionStorage);
53
+ printCheck("policy_secure_storage", !runtime.config.policy.requireSecureStorage || storage?.protected === true, JSON.stringify(runtime.config.policy));
54
+ printCheck("execution_environment", risks.length === 0, risks.length === 0 ? "local" : risks.join(","));
33
55
  const apiClient = new VectorPlaneApiClient(runtime.profile.apiBaseUrl, runtime.config.requestTimeoutMs, runtime.logger);
34
56
  try {
35
57
  const health = await apiClient.getHealth();
@@ -1,12 +1,13 @@
1
- import { getStringOption, parseArgs } from "../core/cli.js";
1
+ import { getBooleanOption, getStringOption, parseArgs } from "../core/cli.js";
2
2
  import { runLoginFlow } from "../core/auth.js";
3
- import { saveSession, setActiveProfile, updateProfileState, upsertProfile } from "../core/config.js";
3
+ import { getSessionStorageInfo, saveSession, setActiveProfile, updateProfileState, upsertProfile } from "../core/config.js";
4
4
  import { collectMachineContext, collectRuntimeContext } from "../core/machine.js";
5
5
  import { loadRuntimeStatus } from "../core/runtime.js";
6
6
  import { VectorPlaneApiClient } from "../core/api.js";
7
7
  export async function runLoginCommand(cliVersion, args) {
8
8
  const parsed = parseArgs(args);
9
9
  const requestedProfile = getStringOption(parsed, "profile");
10
+ const noBrowser = getBooleanOption(parsed, "no-browser") || getBooleanOption(parsed, "manual");
10
11
  if (requestedProfile) {
11
12
  await upsertProfile(requestedProfile, { name: requestedProfile });
12
13
  await setActiveProfile(requestedProfile);
@@ -23,13 +24,21 @@ export async function runLoginCommand(cliVersion, args) {
23
24
  device: runtime.device,
24
25
  apiClient,
25
26
  logger: runtime.logger,
27
+ noBrowser: noBrowser || runtime.config.policy.requireManualLogin,
26
28
  });
27
29
  await saveSession(session, runtime.profile.name);
30
+ const storage = await getSessionStorageInfo(runtime.profile.name);
28
31
  await upsertProfile(runtime.profile.name, { workspace: session.workspace });
29
32
  await updateProfileState(runtime.profile.name, {
30
33
  lastCommand: "login",
31
34
  lastWorkspace: session.workspace,
32
35
  lastError: null,
36
+ lastLoginAt: session.obtainedAt,
37
+ lastSessionId: session.sessionId,
38
+ storageBackend: storage?.backend ?? null,
39
+ storageProtected: storage?.protected ?? null,
40
+ lastAuthFailure: null,
41
+ lastAuthFailureAt: null,
33
42
  });
34
43
  runtime.logger.success("login realizado com sucesso.");
35
44
  process.stdout.write(`Perfil: ${runtime.profile.name}\n`);
@@ -0,0 +1 @@
1
+ export declare function runLogoutCommand(): Promise<number>;
@@ -0,0 +1,37 @@
1
+ import { deleteSession, loadSession, updateProfileState } from "../core/config.js";
2
+ import { loadRuntimeStatus } from "../core/runtime.js";
3
+ import { VectorPlaneApiClient } from "../core/api.js";
4
+ import { revokeSession } from "../core/session.js";
5
+ export async function runLogoutCommand() {
6
+ const runtime = await loadRuntimeStatus();
7
+ const session = await loadSession(runtime.profile.name);
8
+ const loggedOutAt = new Date().toISOString();
9
+ if (!session) {
10
+ runtime.logger.warn("nenhuma sessão ativa para encerrar.");
11
+ await updateProfileState(runtime.profile.name, {
12
+ lastCommand: "logout",
13
+ lastLogoutAt: loggedOutAt,
14
+ });
15
+ return 0;
16
+ }
17
+ const apiClient = new VectorPlaneApiClient(runtime.profile.apiBaseUrl, runtime.config.requestTimeoutMs, runtime.logger);
18
+ try {
19
+ await revokeSession({ session, apiClient });
20
+ }
21
+ catch (error) {
22
+ runtime.logger.warn("não foi possível revogar a sessão remotamente. removendo a sessão local mesmo assim.");
23
+ await updateProfileState(runtime.profile.name, {
24
+ lastAuthFailure: error instanceof Error ? error.message : String(error),
25
+ lastAuthFailureAt: loggedOutAt,
26
+ });
27
+ }
28
+ await deleteSession(runtime.profile.name);
29
+ await updateProfileState(runtime.profile.name, {
30
+ lastCommand: "logout",
31
+ lastLogoutAt: loggedOutAt,
32
+ lastSessionId: null,
33
+ });
34
+ runtime.logger.success("sessão local encerrada.");
35
+ return 0;
36
+ }
37
+ //# sourceMappingURL=logout.js.map
@@ -1,15 +1,16 @@
1
- import { configDirectoryExists, getConfigDirectoryPath, getProfileState, loadQueue, loadSession } from "../core/config.js";
1
+ import { configDirectoryExists, getConfigDirectoryPath, getProfileState, getSessionStorageInfo, loadQueue, loadSession } from "../core/config.js";
2
2
  import { collectGitContext } from "../core/git.js";
3
3
  import { getBoundWorkspace } from "../core/workspace-binding.js";
4
4
  import { loadRuntimeStatus } from "../core/runtime.js";
5
5
  export async function runStatusCommand() {
6
6
  const runtime = await loadRuntimeStatus();
7
- const [hasConfigDir, session, git, configDirectory, queue] = await Promise.all([
7
+ const [hasConfigDir, session, git, configDirectory, queue, storage] = await Promise.all([
8
8
  configDirectoryExists(),
9
9
  loadSession(runtime.profile.name),
10
10
  collectGitContext(process.cwd()),
11
11
  getConfigDirectoryPath(),
12
12
  loadQueue(),
13
+ getSessionStorageInfo(runtime.profile.name),
13
14
  ]);
14
15
  const rootPath = git.rootPath ?? process.cwd();
15
16
  const boundWorkspace = await getBoundWorkspace(rootPath);
@@ -19,6 +20,8 @@ export async function runStatusCommand() {
19
20
  process.stdout.write(`Diretório: ${process.cwd()}\n`);
20
21
  process.stdout.write(`Persistência local: ${configDirectory}\n`);
21
22
  process.stdout.write(`Machine ID: ${runtime.device.machineId}\n`);
23
+ process.stdout.write(`Privacidade da máquina: ${runtime.profile.machinePrivacyProfile}\n`);
24
+ process.stdout.write(`Storage de sessão: ${storage ? `${storage.backend}${storage.protected ? " (protegido)" : " (arquivo)"}` : runtime.config.preferredSessionStorage}\n`);
22
25
  process.stdout.write(`Fila pendente: ${queuedItems}\n`);
23
26
  if (!hasConfigDir || !session) {
24
27
  runtime.logger.warn("sessão não encontrada.");
@@ -29,12 +32,16 @@ export async function runStatusCommand() {
29
32
  process.stdout.write(`Workspace: ${session.workspace}\n`);
30
33
  process.stdout.write(`Workspace vinculado: ${boundWorkspace ?? runtime.profile.workspace ?? "nenhum"}\n`);
31
34
  process.stdout.write(`Sessão de agente: ${profileState.lastSessionId ?? "nenhuma"}\n`);
35
+ process.stdout.write(`Sessão CLI: ${session.sessionId}\n`);
32
36
  process.stdout.write(`Expira em: ${session.expiresAt}\n`);
37
+ process.stdout.write(`Último refresh: ${profileState.lastRefreshAt ?? session.lastRefreshAt ?? "nunca"}\n`);
33
38
  if (git.branch) {
34
39
  process.stdout.write(`Branch: ${git.branch}\n`);
35
40
  }
36
41
  process.stdout.write(`Última sincronização: ${profileState.lastSyncAt ?? "nunca"}\n`);
37
42
  process.stdout.write(`Status do último sync: ${profileState.lastSyncStatus ?? "desconhecido"}\n`);
43
+ process.stdout.write(`Último login: ${profileState.lastLoginAt ?? "desconhecido"}\n`);
44
+ process.stdout.write(`Último logout: ${profileState.lastLogoutAt ?? "nunca"}\n`);
38
45
  if (profileState.lastSnapshotPath) {
39
46
  process.stdout.write(`Último snapshot local: ${profileState.lastSnapshotPath}\n`);
40
47
  }
@@ -10,6 +10,10 @@ export declare class VectorPlaneApiClient {
10
10
  createLoginAttempt(payload: CreateLoginAttemptRequest): Promise<CreateLoginAttemptResponse>;
11
11
  getLoginAttemptStatus(attemptId: string, pollToken: string): Promise<LoginAttemptStatusResponse>;
12
12
  refreshToken(payload: RefreshTokenRequest): Promise<AuthTokenExchangeResponse>;
13
+ revokeToken(payload: RefreshTokenRequest): Promise<{
14
+ revoked: boolean;
15
+ sessionId: string | null;
16
+ }>;
13
17
  getCurrentUser(accessToken: string): Promise<CurrentUserResponse>;
14
18
  sync(accessToken: string, payload: SyncRequest): Promise<SyncResponse>;
15
19
  sendEvent(accessToken: string, payload: EventRequest): Promise<Record<string, unknown>>;
package/dist/core/api.js CHANGED
@@ -92,6 +92,13 @@ export class VectorPlaneApiClient {
92
92
  body: JSON.stringify(payload),
93
93
  }, this.timeoutMs, this.logger);
94
94
  }
95
+ async revokeToken(payload) {
96
+ return requestJson(`${this.apiBaseUrl}/cli/token/revoke`, {
97
+ method: "POST",
98
+ headers: { "Content-Type": "application/json" },
99
+ body: JSON.stringify(payload),
100
+ }, this.timeoutMs, this.logger);
101
+ }
95
102
  async getCurrentUser(accessToken) {
96
103
  return requestJson(`${this.apiBaseUrl}/auth/me`, {
97
104
  method: "GET",
@@ -13,4 +13,5 @@ export declare function runLoginFlow(params: {
13
13
  device: DeviceIdentity;
14
14
  apiClient: VectorPlaneApiClient;
15
15
  logger: Logger;
16
+ noBrowser?: boolean;
16
17
  }): Promise<AuthSession>;
package/dist/core/auth.js CHANGED
@@ -35,12 +35,18 @@ export async function runLoginFlow(params) {
35
35
  client: CLI_CLIENT_ID,
36
36
  });
37
37
  const loginUrl = loginAttempt.loginUrl;
38
- params.logger.info("abrindo navegador para autenticação...");
39
- const opened = await openBrowser(loginUrl);
40
- if (!opened) {
41
- params.logger.warn("não foi possível abrir o navegador automaticamente.");
38
+ if (params.noBrowser) {
39
+ params.logger.info("login manual habilitado. abra a URL abaixo no navegador:");
42
40
  process.stdout.write(`${loginUrl}\n`);
43
41
  }
42
+ else {
43
+ params.logger.info("abrindo navegador para autenticação...");
44
+ const opened = await openBrowser(loginUrl);
45
+ if (!opened) {
46
+ params.logger.warn("não foi possível abrir o navegador automaticamente.");
47
+ process.stdout.write(`${loginUrl}\n`);
48
+ }
49
+ }
44
50
  params.logger.info("aguardando confirmação...");
45
51
  const code = await Promise.any([
46
52
  callbackServer.waitForCallback(CALLBACK_TIMEOUT_MS).then((callback) => callback.code),
@@ -67,11 +73,13 @@ export async function runLoginFlow(params) {
67
73
  return {
68
74
  accessToken: tokenResponse.access_token,
69
75
  refreshToken: tokenResponse.refresh_token,
76
+ sessionId: tokenResponse.session_id,
70
77
  workspace: tokenResponse.workspace,
71
78
  tokenType: tokenResponse.token_type,
72
79
  expiresIn: tokenResponse.expires_in,
73
80
  obtainedAt: new Date().toISOString(),
74
81
  expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(),
82
+ lastRefreshAt: null,
75
83
  };
76
84
  }
77
85
  finally {
@@ -11,6 +11,10 @@ export declare function setActiveProfile(profileName: string): Promise<CliConfig
11
11
  export declare function loadSession(profileName?: string): Promise<AuthSession | null>;
12
12
  export declare function saveSession(session: AuthSession, profileName?: string): Promise<void>;
13
13
  export declare function deleteSession(profileName?: string): Promise<void>;
14
+ export declare function getSessionStorageInfo(profileName?: string): Promise<{
15
+ backend: "system" | "file";
16
+ protected: boolean;
17
+ } | null>;
14
18
  export declare function loadState(): Promise<CliState>;
15
19
  export declare function saveState(state: CliState): Promise<void>;
16
20
  export declare function updateProfileState(profileName: string, patch: Partial<CliProfileState>): Promise<CliState>;
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { CONFIG_DIRECTORY_NAME, CONFIG_FILE_NAME, DEFAULT_CONFIG, DEFAULT_PROFILE, DEFAULT_PROFILE_NAME, DEFAULT_PROFILE_STATE, DEFAULT_QUEUE, DEFAULT_STATE, DEFAULT_WORKSPACE_BINDINGS, DEVICE_FILE_NAME, QUEUE_FILE_NAME, SESSION_FILE_NAME, SNAPSHOTS_DIRECTORY_NAME, STATE_FILE_NAME, WORKSPACE_BINDINGS_FILE_NAME, } from "./constants.js";
6
6
  import { ConfigError } from "./errors.js";
7
+ import { deleteSystemSession, loadSystemSession, resolveSessionStorage, saveSystemSession } from "./secure-store.js";
7
8
  function configDirectory() {
8
9
  return path.join(os.homedir(), CONFIG_DIRECTORY_NAME);
9
10
  }
@@ -72,6 +73,10 @@ export async function loadConfig() {
72
73
  ...DEFAULT_CONFIG,
73
74
  ...stored,
74
75
  profiles,
76
+ policy: {
77
+ ...DEFAULT_CONFIG.policy,
78
+ ...(stored?.policy ?? {}),
79
+ },
75
80
  activeProfile: stored?.activeProfile && profiles[stored.activeProfile] ? stored.activeProfile : DEFAULT_PROFILE_NAME,
76
81
  };
77
82
  }
@@ -105,7 +110,19 @@ export async function setActiveProfile(profileName) {
105
110
  return next;
106
111
  }
107
112
  async function loadSessionStore() {
108
- return (await readJsonFile(configFilePath(SESSION_FILE_NAME))) ?? { profiles: {} };
113
+ const stored = (await readJsonFile(configFilePath(SESSION_FILE_NAME))) ?? { profiles: {} };
114
+ const normalizedProfiles = Object.fromEntries(Object.entries(stored.profiles ?? {}).map(([profile, value]) => {
115
+ if (value && typeof value === "object" && "backend" in value && "session" in value) {
116
+ return [profile, value];
117
+ }
118
+ const legacy = value;
119
+ return [profile, {
120
+ backend: "file",
121
+ protected: false,
122
+ session: legacy,
123
+ }];
124
+ }));
125
+ return { profiles: normalizedProfiles };
109
126
  }
110
127
  async function saveSessionStore(store) {
111
128
  await writeJsonFile(configFilePath(SESSION_FILE_NAME), store);
@@ -113,22 +130,83 @@ async function saveSessionStore(store) {
113
130
  export async function loadSession(profileName) {
114
131
  const config = await loadConfig();
115
132
  const store = await loadSessionStore();
116
- return store.profiles[profileName ?? getActiveProfileName(config)] ?? null;
133
+ const profile = profileName ?? getActiveProfileName(config);
134
+ const record = store.profiles[profile];
135
+ if (!record) {
136
+ return null;
137
+ }
138
+ if (record.backend === "system") {
139
+ const protectedSession = await loadSystemSession(profile);
140
+ if (!protectedSession) {
141
+ return null;
142
+ }
143
+ return protectedSession;
144
+ }
145
+ if (!record.session.accessToken || !record.session.refreshToken) {
146
+ return null;
147
+ }
148
+ return {
149
+ accessToken: record.session.accessToken,
150
+ refreshToken: record.session.refreshToken,
151
+ sessionId: record.session.sessionId ?? "legacy-session",
152
+ workspace: record.session.workspace,
153
+ tokenType: record.session.tokenType,
154
+ expiresIn: record.session.expiresIn,
155
+ obtainedAt: record.session.obtainedAt,
156
+ expiresAt: record.session.expiresAt,
157
+ lastRefreshAt: record.session.lastRefreshAt ?? null,
158
+ };
117
159
  }
118
160
  export async function saveSession(session, profileName) {
119
161
  const config = await loadConfig();
120
162
  const activeProfile = profileName ?? getActiveProfileName(config);
121
163
  const store = await loadSessionStore();
122
- store.profiles[activeProfile] = session;
164
+ const storage = await resolveSessionStorage(config);
165
+ if (storage.backend === "system") {
166
+ await saveSystemSession(activeProfile, session);
167
+ store.profiles[activeProfile] = {
168
+ backend: "system",
169
+ protected: true,
170
+ session: {
171
+ sessionId: session.sessionId,
172
+ workspace: session.workspace,
173
+ tokenType: session.tokenType,
174
+ expiresIn: session.expiresIn,
175
+ obtainedAt: session.obtainedAt,
176
+ expiresAt: session.expiresAt,
177
+ lastRefreshAt: session.lastRefreshAt,
178
+ },
179
+ };
180
+ }
181
+ else {
182
+ store.profiles[activeProfile] = {
183
+ backend: "file",
184
+ protected: false,
185
+ session,
186
+ };
187
+ }
123
188
  await saveSessionStore(store);
124
189
  }
125
190
  export async function deleteSession(profileName) {
126
191
  const config = await loadConfig();
127
192
  const activeProfile = profileName ?? getActiveProfileName(config);
128
193
  const store = await loadSessionStore();
194
+ if (store.profiles[activeProfile]?.backend === "system") {
195
+ await deleteSystemSession(activeProfile);
196
+ }
129
197
  delete store.profiles[activeProfile];
130
198
  await saveSessionStore(store);
131
199
  }
200
+ export async function getSessionStorageInfo(profileName) {
201
+ const config = await loadConfig();
202
+ const activeProfile = profileName ?? getActiveProfileName(config);
203
+ const store = await loadSessionStore();
204
+ const record = store.profiles[activeProfile];
205
+ if (!record) {
206
+ return null;
207
+ }
208
+ return { backend: record.backend, protected: record.protected };
209
+ }
132
210
  export async function loadState() {
133
211
  const stored = await readJsonFile(configFilePath(STATE_FILE_NAME));
134
212
  const profiles = Object.entries(stored?.profiles ?? DEFAULT_STATE.profiles).reduce((accumulator, [name, value]) => {
@@ -17,6 +17,7 @@ export const DEFAULT_PROFILE = {
17
17
  workspace: null,
18
18
  orgId: null,
19
19
  environment: "production",
20
+ machinePrivacyProfile: "standard",
20
21
  };
21
22
  export const DEFAULT_CONFIG = {
22
23
  activeProfile: DEFAULT_PROFILE_NAME,
@@ -32,6 +33,15 @@ export const DEFAULT_CONFIG = {
32
33
  telemetryEnabled: false,
33
34
  queueMaxRetries: 5,
34
35
  snapshotStoreLimit: 20,
36
+ preferredSessionStorage: "system",
37
+ policy: {
38
+ requireSecureStorage: false,
39
+ disableFileSessionFallback: false,
40
+ requireManualLogin: false,
41
+ blockPublicIpLookup: false,
42
+ blockMacAddressCollection: false,
43
+ blockUsernameCollection: false,
44
+ },
35
45
  };
36
46
  export const DEFAULT_PROFILE_STATE = {
37
47
  lastSyncAt: null,
@@ -42,6 +52,13 @@ export const DEFAULT_PROFILE_STATE = {
42
52
  lastError: null,
43
53
  lastSnapshotPath: null,
44
54
  lastSessionId: null,
55
+ lastLoginAt: null,
56
+ lastRefreshAt: null,
57
+ lastLogoutAt: null,
58
+ storageBackend: null,
59
+ storageProtected: null,
60
+ lastAuthFailure: null,
61
+ lastAuthFailureAt: null,
45
62
  };
46
63
  export const DEFAULT_STATE = {
47
64
  activeProfile: DEFAULT_PROFILE_NAME,
@@ -61,6 +61,15 @@ export async function collectRuntimeContext(cliVersion, command, args) {
61
61
  }
62
62
  export async function collectMachineContext(identity, config) {
63
63
  const cpus = os.cpus();
64
+ const privacyProfile = config.profiles[config.activeProfile]?.machinePrivacyProfile ?? "standard";
65
+ const collectPublicIp = !config.policy.blockPublicIpLookup && privacyProfile !== "minimal";
66
+ const includeMacAddress = !config.policy.blockMacAddressCollection && privacyProfile === "enterprise";
67
+ const includeUsername = !config.policy.blockUsernameCollection && privacyProfile !== "minimal";
68
+ const networkInterfaces = collectNetworkInterfaces().map((entry) => ({
69
+ ...entry,
70
+ mac: includeMacAddress ? entry.mac : null,
71
+ }));
72
+ const publicIp = collectPublicIp ? await detectPublicIp(config.publicIpLookupTimeoutMs) : null;
64
73
  return {
65
74
  machineId: identity.machineId,
66
75
  hostname: os.hostname(),
@@ -68,7 +77,7 @@ export async function collectMachineContext(identity, config) {
68
77
  osRelease: os.release(),
69
78
  architecture: os.arch(),
70
79
  kernelVersion: os.version(),
71
- username: os.userInfo().username,
80
+ username: includeUsername ? os.userInfo().username : null,
72
81
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
73
82
  locale: Intl.DateTimeFormat().resolvedOptions().locale,
74
83
  shell: process.env.SHELL ?? null,
@@ -80,8 +89,9 @@ export async function collectMachineContext(identity, config) {
80
89
  memoryTotal: os.totalmem(),
81
90
  memoryFree: os.freemem(),
82
91
  uptimeSeconds: os.uptime(),
83
- networkInterfaces: collectNetworkInterfaces(),
84
- publicIp: await detectPublicIp(config.publicIpLookupTimeoutMs),
92
+ networkInterfaces: privacyProfile === "minimal" ? [] : networkInterfaces,
93
+ publicIp,
94
+ privacyProfile,
85
95
  };
86
96
  }
87
97
  //# sourceMappingURL=machine.js.map
@@ -0,0 +1,10 @@
1
+ import type { AuthSession } from "../types/auth.js";
2
+ import type { CliConfig } from "../types/config.js";
3
+ export interface SessionStorageInfo {
4
+ backend: "system" | "file";
5
+ protected: boolean;
6
+ }
7
+ export declare function resolveSessionStorage(config: CliConfig): Promise<SessionStorageInfo>;
8
+ export declare function saveSystemSession(profileName: string, session: AuthSession): Promise<SessionStorageInfo>;
9
+ export declare function loadSystemSession(profileName: string): Promise<AuthSession | null>;
10
+ export declare function deleteSystemSession(profileName: string): Promise<void>;
@@ -0,0 +1,137 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { ConfigError } from "./errors.js";
4
+ const execFileAsync = promisify(execFile);
5
+ const SERVICE_NAME = "vectorplane-ctrl-cli";
6
+ async function commandExists(command) {
7
+ try {
8
+ await execFileAsync("sh", ["-lc", `command -v ${command}`]);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ async function storeMacSession(profileName, session) {
16
+ await execFileAsync("security", [
17
+ "add-generic-password",
18
+ "-a",
19
+ profileName,
20
+ "-s",
21
+ SERVICE_NAME,
22
+ "-U",
23
+ "-w",
24
+ JSON.stringify(session),
25
+ ]);
26
+ }
27
+ async function loadMacSession(profileName) {
28
+ try {
29
+ const { stdout } = await execFileAsync("security", [
30
+ "find-generic-password",
31
+ "-a",
32
+ profileName,
33
+ "-s",
34
+ SERVICE_NAME,
35
+ "-w",
36
+ ]);
37
+ return JSON.parse(stdout.trim());
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ async function deleteMacSession(profileName) {
44
+ try {
45
+ await execFileAsync("security", ["delete-generic-password", "-a", profileName, "-s", SERVICE_NAME]);
46
+ }
47
+ catch {
48
+ // Ignore missing entry.
49
+ }
50
+ }
51
+ async function storeLinuxSession(profileName, session) {
52
+ await new Promise((resolve, reject) => {
53
+ const child = spawn("secret-tool", ["store", "--label", `VectorPlane CLI ${profileName}`, "service", SERVICE_NAME, "account", profileName], {
54
+ stdio: ["pipe", "ignore", "pipe"],
55
+ });
56
+ let stderr = "";
57
+ child.stderr.on("data", (chunk) => {
58
+ stderr += String(chunk);
59
+ });
60
+ child.on("error", reject);
61
+ child.on("close", (code) => {
62
+ if (code === 0) {
63
+ resolve();
64
+ return;
65
+ }
66
+ reject(new ConfigError(stderr.trim() || "Falha ao salvar sessão no secure storage do sistema."));
67
+ });
68
+ child.stdin.write(JSON.stringify(session));
69
+ child.stdin.end();
70
+ });
71
+ }
72
+ async function loadLinuxSession(profileName) {
73
+ try {
74
+ const { stdout } = await execFileAsync("secret-tool", ["lookup", "service", SERVICE_NAME, "account", profileName]);
75
+ return JSON.parse(stdout.trim());
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ async function deleteLinuxSession(profileName) {
82
+ try {
83
+ await execFileAsync("secret-tool", ["clear", "service", SERVICE_NAME, "account", profileName]);
84
+ }
85
+ catch {
86
+ // Ignore missing entry.
87
+ }
88
+ }
89
+ async function systemBackendAvailable() {
90
+ if (process.platform === "darwin") {
91
+ return commandExists("security");
92
+ }
93
+ if (process.platform === "linux") {
94
+ return commandExists("secret-tool");
95
+ }
96
+ return false;
97
+ }
98
+ export async function resolveSessionStorage(config) {
99
+ const systemAvailable = await systemBackendAvailable();
100
+ if (config.preferredSessionStorage === "system" && systemAvailable) {
101
+ return { backend: "system", protected: true };
102
+ }
103
+ if (config.policy.requireSecureStorage || config.policy.disableFileSessionFallback) {
104
+ throw new ConfigError("Secure storage do sistema é obrigatório neste perfil, mas não está disponível.");
105
+ }
106
+ return { backend: "file", protected: false };
107
+ }
108
+ export async function saveSystemSession(profileName, session) {
109
+ if (process.platform === "darwin") {
110
+ await storeMacSession(profileName, session);
111
+ return { backend: "system", protected: true };
112
+ }
113
+ if (process.platform === "linux") {
114
+ await storeLinuxSession(profileName, session);
115
+ return { backend: "system", protected: true };
116
+ }
117
+ throw new ConfigError("Secure storage não suportado neste sistema operacional.");
118
+ }
119
+ export async function loadSystemSession(profileName) {
120
+ if (process.platform === "darwin") {
121
+ return loadMacSession(profileName);
122
+ }
123
+ if (process.platform === "linux") {
124
+ return loadLinuxSession(profileName);
125
+ }
126
+ return null;
127
+ }
128
+ export async function deleteSystemSession(profileName) {
129
+ if (process.platform === "darwin") {
130
+ await deleteMacSession(profileName);
131
+ return;
132
+ }
133
+ if (process.platform === "linux") {
134
+ await deleteLinuxSession(profileName);
135
+ }
136
+ }
137
+ //# sourceMappingURL=secure-store.js.map
@@ -13,4 +13,11 @@ export declare function ensureFreshSession(params: {
13
13
  apiClient: VectorPlaneApiClient;
14
14
  logger: Logger;
15
15
  }): Promise<AuthSession>;
16
+ export declare function revokeSession(params: {
17
+ session: AuthSession;
18
+ apiClient: VectorPlaneApiClient;
19
+ }): Promise<{
20
+ revoked: boolean;
21
+ sessionId: string | null;
22
+ }>;
16
23
  export declare function resolveWorkspaceSlug(profile: CliProfileConfig, session: AuthSession | null, explicit: string | undefined, bound: string | null, stateWorkspace: string | null): string | null;
@@ -1,6 +1,6 @@
1
1
  import { AuthError } from "./errors.js";
2
2
  import { CLI_CLIENT_ID } from "./constants.js";
3
- import { saveSession } from "./config.js";
3
+ import { saveSession, updateProfileState } from "./config.js";
4
4
  export function isSessionExpired(session, offsetMs = 60_000) {
5
5
  return new Date(session.expiresAt).getTime() <= Date.now() + offsetMs;
6
6
  }
@@ -20,15 +20,31 @@ export async function ensureFreshSession(params) {
20
20
  const nextSession = {
21
21
  accessToken: refreshed.access_token,
22
22
  refreshToken: refreshed.refresh_token,
23
+ sessionId: refreshed.session_id || params.session.sessionId,
23
24
  workspace: refreshed.workspace || params.session.workspace,
24
25
  tokenType: refreshed.token_type,
25
26
  expiresIn: refreshed.expires_in,
26
27
  obtainedAt: new Date().toISOString(),
27
28
  expiresAt: new Date(Date.now() + refreshed.expires_in * 1000).toISOString(),
29
+ lastRefreshAt: new Date().toISOString(),
28
30
  };
29
31
  await saveSession(nextSession, params.profileName);
32
+ await updateProfileState(params.profileName, {
33
+ lastRefreshAt: nextSession.lastRefreshAt,
34
+ lastSessionId: nextSession.sessionId,
35
+ lastError: null,
36
+ lastAuthFailure: null,
37
+ lastAuthFailureAt: null,
38
+ });
30
39
  return nextSession;
31
40
  }
41
+ export async function revokeSession(params) {
42
+ const refreshPayload = {
43
+ refresh_token: params.session.refreshToken,
44
+ client: CLI_CLIENT_ID,
45
+ };
46
+ return params.apiClient.revokeToken(refreshPayload);
47
+ }
32
48
  export function resolveWorkspaceSlug(profile, session, explicit, bound, stateWorkspace) {
33
49
  return explicit ?? bound ?? profile.workspace ?? session?.workspace ?? stateWorkspace ?? null;
34
50
  }
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { runContextCommand } from "./commands/context.js";
7
7
  import { runDoctorCommand } from "./commands/doctor.js";
8
8
  import { runEventCommand } from "./commands/event.js";
9
9
  import { runLoginCommand } from "./commands/login.js";
10
+ import { runLogoutCommand } from "./commands/logout.js";
10
11
  import { runSessionCommand } from "./commands/session.js";
11
12
  import { runStatusCommand } from "./commands/status.js";
12
13
  import { runSyncCommand } from "./commands/sync.js";
@@ -25,7 +26,8 @@ function printHelp() {
25
26
  process.stdout.write("Uso: vp <comando>\n");
26
27
  process.stdout.write("\n");
27
28
  process.stdout.write("Comandos principais:\n");
28
- process.stdout.write(" login\n");
29
+ process.stdout.write(" login [--no-browser|--manual]\n");
30
+ process.stdout.write(" logout\n");
29
31
  process.stdout.write(" sync [--force]\n");
30
32
  process.stdout.write(" status\n");
31
33
  process.stdout.write(" whoami\n");
@@ -50,6 +52,8 @@ export async function runCli(args) {
50
52
  return runLoginCommand(cliVersion, rest);
51
53
  case "sync":
52
54
  return runSyncCommand(cliVersion, rest);
55
+ case "logout":
56
+ return runLogoutCommand();
53
57
  case "status":
54
58
  return runStatusCommand();
55
59
  case "whoami":
@@ -31,6 +31,7 @@ export interface AuthTokenExchangeResponse {
31
31
  workspace: string;
32
32
  expires_in: number;
33
33
  token_type: string;
34
+ session_id: string;
34
35
  }
35
36
  export interface RefreshTokenRequest {
36
37
  refresh_token: string;
@@ -48,9 +49,11 @@ export interface CurrentUserResponse {
48
49
  export interface AuthSession {
49
50
  accessToken: string;
50
51
  refreshToken: string;
52
+ sessionId: string;
51
53
  workspace: string;
52
54
  tokenType: string;
53
55
  expiresIn: number;
54
56
  obtainedAt: string;
55
57
  expiresAt: string;
58
+ lastRefreshAt: string | null;
56
59
  }
@@ -1,4 +1,14 @@
1
1
  export type VectorPlaneEnvironment = "production" | "preview" | "self-hosted";
2
+ export type MachinePrivacyProfile = "standard" | "minimal" | "enterprise";
3
+ export type SessionStorageBackend = "system" | "file";
4
+ export interface CliPolicyConfig {
5
+ requireSecureStorage: boolean;
6
+ disableFileSessionFallback: boolean;
7
+ requireManualLogin: boolean;
8
+ blockPublicIpLookup: boolean;
9
+ blockMacAddressCollection: boolean;
10
+ blockUsernameCollection: boolean;
11
+ }
2
12
  export interface CliProfileConfig {
3
13
  name: string;
4
14
  apiBaseUrl: string;
@@ -6,6 +16,7 @@ export interface CliProfileConfig {
6
16
  workspace: string | null;
7
17
  orgId: string | null;
8
18
  environment: VectorPlaneEnvironment;
19
+ machinePrivacyProfile: MachinePrivacyProfile;
9
20
  }
10
21
  export interface CliConfig {
11
22
  activeProfile: string;
@@ -19,6 +30,8 @@ export interface CliConfig {
19
30
  telemetryEnabled: boolean;
20
31
  queueMaxRetries: number;
21
32
  snapshotStoreLimit: number;
33
+ preferredSessionStorage: SessionStorageBackend;
34
+ policy: CliPolicyConfig;
22
35
  }
23
36
  export interface CliProfileState {
24
37
  lastSyncAt: string | null;
@@ -29,6 +42,13 @@ export interface CliProfileState {
29
42
  lastError: string | null;
30
43
  lastSnapshotPath: string | null;
31
44
  lastSessionId: string | null;
45
+ lastLoginAt: string | null;
46
+ lastRefreshAt: string | null;
47
+ lastLogoutAt: string | null;
48
+ storageBackend: SessionStorageBackend | null;
49
+ storageProtected: boolean | null;
50
+ lastAuthFailure: string | null;
51
+ lastAuthFailureAt: string | null;
32
52
  }
33
53
  export interface CliState {
34
54
  activeProfile: string;
@@ -5,7 +5,7 @@ export interface SafeEnvironmentVariable {
5
5
  export interface NetworkInterfaceContext {
6
6
  name: string;
7
7
  family: string;
8
- mac: string;
8
+ mac: string | null;
9
9
  address: string;
10
10
  internal: boolean;
11
11
  cidr: string | null;
@@ -32,7 +32,7 @@ export interface MachineContext {
32
32
  osRelease: string;
33
33
  architecture: string;
34
34
  kernelVersion: string;
35
- username: string;
35
+ username: string | null;
36
36
  timezone: string;
37
37
  locale: string;
38
38
  shell: string | null;
@@ -46,4 +46,5 @@ export interface MachineContext {
46
46
  uptimeSeconds: number;
47
47
  networkInterfaces: NetworkInterfaceContext[];
48
48
  publicIp: string | null;
49
+ privacyProfile: "standard" | "minimal" | "enterprise";
49
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectorplane/ctrl-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Official VectorPlane CLI.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",