rvlib-shipkit 1.5.0 → 1.6.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/CHANGELOG.md +46 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +12 -8
- package/src/assets/server/studio-server.ts +14 -2
- package/src/check.ts +5 -8
- package/src/checks/SK001-package-json-shape.check.ts +5 -4
- package/src/checks/SK003-claude-md-shape.check.ts +2 -2
- package/src/checks/SK030-cache-audit.check.ts +34 -0
- package/src/checks/SK031-trace-audit.check.ts +39 -0
- package/src/checks/SK032-ignored-inputs.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +10 -6
- package/src/cli/cmd_CacheAudit.ts +43 -0
- package/src/cli/cmd_CrossRepoActivity.ts +2 -1
- package/src/cli/cmd_GlobalProcesses.ts +4 -3
- package/src/cli/cmd_Home.ts +3 -2
- package/src/cli/cmd_Keys.ts +12 -1
- package/src/cli/cmd_Overview.ts +1 -1
- package/src/cli/cmd_Serve.ts +2 -1
- package/src/cli/cmd_TraceAudit.ts +62 -0
- package/src/cli.ts +4 -0
- package/src/collections/collection-types.ts +3 -0
- package/src/collections/providers.ts +5 -3
- package/src/core/app-icon.ts +154 -0
- package/src/core/audit-report.ts +21 -0
- package/src/core/cache-audit.ts +72 -0
- package/src/core/canonical-scripts.ts +1 -1
- package/src/core/check-ctx.ts +14 -5
- package/src/core/check-runner.ts +6 -3
- package/src/core/floor-resolve.ts +5 -7
- package/src/core/git-root.ts +13 -0
- package/src/core/ignored-block.ts +94 -0
- package/src/core/ignored-inputs.ts +47 -0
- package/src/core/jobs.ts +9 -2
- package/src/core/one-lib.ts +59 -4
- package/src/core/read-set.ts +97 -9
- package/src/core/run-ci.ts +6 -1
- package/src/core/run-fixers.ts +8 -2
- package/src/core/trace-audit.ts +120 -0
- package/src/home/gui-app.ts +24 -27
- package/src/home/home-start.ts +11 -5
- package/src/home/open-app.ts +2 -2
- package/src/home/via-hub.ts +14 -0
- package/src/ledger/db.ts +2 -13
- package/src/ledger/gate-checks.ts +21 -3
- package/src/ledger/git-key.ts +148 -30
- package/src/ledger/git-scan.ts +5 -2
- package/src/ledger/ledger.ts +131 -16
- package/src/ledger/store.ts +14 -0
- package/src/ledger/toolchain-identity.ts +210 -25
- package/src/problems/extract.ts +3 -0
- package/src/repo-config.ts +18 -1
- package/src/serve/hub-app/admin-svc.ts +36 -3
- package/src/serve/hub-app/admin-view.tsx +33 -1
- package/src/serve/hub-app/col-card.tsx +1 -44
- package/src/serve/hub-app/col-tables-view.tsx +71 -5
- package/src/serve/hub-app/col-tables.ts +27 -0
- package/src/serve/hub-app/home-st.ts +1 -1
- package/src/serve/hub-app/home-strip.css +5 -9
- package/src/serve/hub-app/home-strip.tsx +4 -17
- package/src/serve/hub-app/hub-app.tsx +18 -4
- package/src/serve/hub-app/hub.css +24 -20
- package/src/serve/hub-app/keys.ts +10 -2
- package/src/serve/hub-app/processes-view.tsx +16 -9
- package/src/serve/hub-app/project-list.css +4 -1
- package/src/serve/hub-app/project-list.tsx +6 -3
- package/src/serve/hub-app/projects-svc.ts +10 -0
- package/src/serve/hub-app/repo-sections.ts +31 -17
- package/src/serve/hub-app/repo-st.ts +24 -5
- package/src/serve/hub-app/sidebar.tsx +5 -5
- package/src/serve/hub-app/tree-build.ts +2 -0
- package/src/serve/hub-app/tree-row.tsx +18 -5
- package/src/serve/hub-app/tree-svc.ts +27 -7
- package/src/serve/hub-runtime.ts +4 -2
- package/src/serve/project-gutter-route.ts +22 -1
- package/src/suite/judge-suites.ts +6 -0
- package/src/trace/interpose.c +655 -0
- package/src/trace/read-trace.ts +485 -0
- package/src/trace/tracer.ts +282 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// IGNORED INPUTS, the pure half: the kept audit's shape and what a task's key
|
|
2
|
+
// takes from it and from `repo.config.ts ignoredInputs`. No fs here, so a
|
|
3
|
+
// check keyed on its reads can import it (core/audit-report.ts reads the file).
|
|
4
|
+
|
|
5
|
+
export type AuditTask = {
|
|
6
|
+
id: string
|
|
7
|
+
/** `audited` when its trace had no hole, else why it could not be */
|
|
8
|
+
status: 'audited' | 'unaudited'
|
|
9
|
+
why?: string
|
|
10
|
+
/** ignored paths it read that its key does not cover */
|
|
11
|
+
uncovered: string[]
|
|
12
|
+
/** the fewest `ignoredInputs` entries that cover them */
|
|
13
|
+
propose: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type AuditReport = {
|
|
17
|
+
at: number
|
|
18
|
+
/** why nothing was traced on this machine, when nothing was */
|
|
19
|
+
skipped?: string
|
|
20
|
+
tasks: AuditTask[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** what the kept audit says this task reads outside its key */
|
|
24
|
+
export function auditedExtras(r: AuditReport | null, id: string): string[] {
|
|
25
|
+
if (r == null || r.skipped != null) return []
|
|
26
|
+
return r.tasks.find((x) => x.id === id)?.propose ?? []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** the audit's entries that `have` does not carry yet, per task */
|
|
30
|
+
export function pendingByTask(r: AuditReport | null, have: IgnoredInputs): IgnoredInputs {
|
|
31
|
+
const out: IgnoredInputs = {}
|
|
32
|
+
if (r == null || r.skipped != null) return out
|
|
33
|
+
for (const t of r.tasks) {
|
|
34
|
+
const missing = (t.propose ?? []).filter((p) => !(have['*'] ?? []).includes(p) && !(have[t.id] ?? []).includes(p))
|
|
35
|
+
if (missing.length > 0) out[t.id] = missing
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `ignoredInputs`: task id → gitignored paths it reads, `'*'` for every task */
|
|
41
|
+
export type IgnoredInputs = Record<string, string[]>
|
|
42
|
+
|
|
43
|
+
/** the entries that join one task's key */
|
|
44
|
+
export const ignoredFor = (ii: IgnoredInputs | undefined, id: string): string[] => [...(ii?.['*'] ?? []), ...(ii?.[id] ?? [])]
|
|
45
|
+
|
|
46
|
+
/** `task: a, b; other: c`, for a line */
|
|
47
|
+
export const describeByTask = (by: IgnoredInputs): string => Object.entries(by).map(([t, ps]) => `${t}: ${ps.join(', ')}`).join('; ')
|
package/src/core/jobs.ts
CHANGED
|
@@ -58,7 +58,14 @@ let seq = 0
|
|
|
58
58
|
/** what a job the daemon starts carries beside its flags: the run's options
|
|
59
59
|
* as the runner takes them, one lib copy for the repo config, and the
|
|
60
60
|
* machine's one background turn for a run nobody waits on */
|
|
61
|
-
export type JobOpts = {
|
|
61
|
+
export type JobOpts = {
|
|
62
|
+
opts: Record<string, unknown>
|
|
63
|
+
oneLib?: boolean
|
|
64
|
+
turn?: boolean
|
|
65
|
+
/** the switches the asking terminal had set (home/via-hub.ts): the job
|
|
66
|
+
* starts from the daemon's env, which never saw them */
|
|
67
|
+
env?: Record<string, string>
|
|
68
|
+
}
|
|
62
69
|
|
|
63
70
|
/** start `shipkit <kind> <args> --job <id>` detached from this process and
|
|
64
71
|
* its terminal, and return at once */
|
|
@@ -69,7 +76,7 @@ export function startJob(root: string, o: { cli: string; kind: JobKind; args: st
|
|
|
69
76
|
mkdirSync(dir, { recursive: true })
|
|
70
77
|
if (o.job != null) atomicWrite(jobOpts(root, id), JSON.stringify(o.job))
|
|
71
78
|
const out = openSync(join(dir, 'out.log'), 'a')
|
|
72
|
-
const child = spawn(process.execPath, [o.cli, o.kind, ...o.args, '--job', id], { cwd: root, detached: true, stdio: ['ignore', out, out], env: process.env })
|
|
79
|
+
const child = spawn(process.execPath, [o.cli, o.kind, ...o.args, '--job', id], { cwd: root, detached: true, stdio: ['ignore', out, out], env: { ...process.env, ...o.job?.env } })
|
|
73
80
|
closeSync(out)
|
|
74
81
|
child.unref()
|
|
75
82
|
const pid = child.pid ?? 0
|
package/src/core/one-lib.ts
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
// (the home) makes every `rvlib-shipkit` import, from any repo config or check
|
|
3
3
|
// file, land on its OWN copy. A repo's install is never loaded, so there is one
|
|
4
4
|
// module instance per lib file: one mobx, one class identity, one version.
|
|
5
|
+
// The same holds for a package whose objects cross between a repo file and
|
|
6
|
+
// the toolkit (SHARED): a repo's brand draws on a canvas the toolkit made,
|
|
7
|
+
// and a native class only accepts instances of its own copy.
|
|
5
8
|
//
|
|
6
9
|
// the one constraint a reader breaks first: register it BEFORE the first repo
|
|
7
10
|
// config is imported. A module already loaded keeps the copy it resolved.
|
|
8
|
-
import { readFileSync, realpathSync } from 'fs'
|
|
9
|
-
import { extname, join } from 'path'
|
|
11
|
+
import { existsSync, readFileSync, realpathSync } from 'fs'
|
|
12
|
+
import { dirname, extname, join } from 'path'
|
|
10
13
|
import { LIB_ROOT } from './lib-root.ts'
|
|
11
14
|
|
|
12
15
|
const NAME = 'rvlib-shipkit'
|
|
@@ -54,6 +57,46 @@ const real = (p: string): string => {
|
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
59
|
|
|
60
|
+
/** packages a repo file and the toolkit hand objects of to each other */
|
|
61
|
+
export const SHARED: readonly string[] = ['@napi-rs/canvas', 'comfy-ts']
|
|
62
|
+
|
|
63
|
+
/** a file of ANOTHER copy of a shared package, as the path inside that copy,
|
|
64
|
+
* else null. `host` is the root of the copy this process loads */
|
|
65
|
+
export function foreignSharedPath(path: string, name: string, host: string | null): string | null {
|
|
66
|
+
const at = path.lastIndexOf(`/node_modules/${name}/`)
|
|
67
|
+
if (at === -1) return null
|
|
68
|
+
const root = path.slice(0, at + `/node_modules/${name}`.length)
|
|
69
|
+
if (host == null || root === host || real(root) === real(host)) return null
|
|
70
|
+
return path.slice(root.length + 1)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const hostRoot = (name: string, from: string): string | null => {
|
|
74
|
+
try {
|
|
75
|
+
return dirname(Bun.resolveSync(`${name}/package.json`, from))
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** where a foreign copy's file lands in the host copy: its entry on the
|
|
82
|
+
* host's entry (the two can name different files), any other file on the
|
|
83
|
+
* same path, else on the entry */
|
|
84
|
+
const sharedTarget = (path: string, sub: string, name: string, host: string): string => {
|
|
85
|
+
const entry = Bun.resolveSync(name, host)
|
|
86
|
+
const foreignDir = path.slice(0, path.length - sub.length - 1)
|
|
87
|
+
try {
|
|
88
|
+
if (real(Bun.resolveSync(name, foreignDir)) === real(path)) return entry
|
|
89
|
+
} catch {
|
|
90
|
+
/* a foreign copy with no entry of its own: its files map by path */
|
|
91
|
+
}
|
|
92
|
+
return existsSync(join(host, sub)) ? join(host, sub) : entry
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const reexport = (target: string): { contents: string; loader: 'ts' } => {
|
|
96
|
+
const t = JSON.stringify(target)
|
|
97
|
+
return { contents: `export * from ${t}\nimport * as own from ${t}\nexport default (own as { default?: unknown }).default\n`, loader: 'ts' }
|
|
98
|
+
}
|
|
99
|
+
|
|
57
100
|
const LOADERS: Record<string, 'ts' | 'tsx' | 'js' | 'jsx'> = { '.ts': 'ts', '.tsx': 'tsx', '.js': 'js', '.mjs': 'js', '.jsx': 'jsx' }
|
|
58
101
|
|
|
59
102
|
let installed = false
|
|
@@ -77,9 +120,21 @@ export function useOneLib(root: string = LIB_ROOT): void {
|
|
|
77
120
|
const loader = LOADERS[extname(a.path)] ?? 'ts'
|
|
78
121
|
const sub = foreignLibPath(a.path, root)
|
|
79
122
|
if (sub == null) return { contents: readFileSync(a.path, 'utf8'), loader }
|
|
80
|
-
|
|
81
|
-
return { contents: `export * from ${target}\nimport * as own from ${target}\nexport default (own as { default?: unknown }).default\n`, loader: 'ts' }
|
|
123
|
+
return reexport(join(root, sub))
|
|
82
124
|
})
|
|
125
|
+
for (const name of SHARED) {
|
|
126
|
+
const host = hostRoot(name, root)
|
|
127
|
+
if (host == null) continue
|
|
128
|
+
// the host copy never matches: a CommonJS file read back here
|
|
129
|
+
// loses the named exports bun infers for it
|
|
130
|
+
const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
|
|
131
|
+
const skip = [...new Set([host, real(host)])].map((h) => `(?!${esc(h)}/)`).join('')
|
|
132
|
+
build.onLoad({ filter: new RegExp(`^${skip}.*/node_modules/${esc(name)}/`) }, (a) => {
|
|
133
|
+
const sub = foreignSharedPath(a.path, name, host)
|
|
134
|
+
if (sub == null) return { contents: readFileSync(a.path, 'utf8'), loader: LOADERS[extname(a.path)] ?? 'js' }
|
|
135
|
+
return reexport(sharedTarget(a.path, sub, name, host))
|
|
136
|
+
})
|
|
137
|
+
}
|
|
83
138
|
},
|
|
84
139
|
})
|
|
85
140
|
}
|
package/src/core/read-set.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
// a millisecond later: the next run hashes the disk again, the two differ, and
|
|
10
10
|
// the check runs. Nothing here can turn a mid-run edit into a replay.
|
|
11
11
|
import { createHash } from 'crypto'
|
|
12
|
-
import { lstatSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
13
|
-
import { join } from 'path'
|
|
12
|
+
import { lstatSync, readdirSync, readFileSync, readlinkSync, statSync } from 'fs'
|
|
13
|
+
import { isAbsolute, join } from 'path'
|
|
14
14
|
|
|
15
15
|
/** `file` or `dir` when the path was there, null when it was not */
|
|
16
16
|
export type PathKind = 'file' | 'dir' | null
|
|
@@ -29,6 +29,60 @@ export type ReadSet = {
|
|
|
29
29
|
/** repo-relative path → whether a symlink stood there. Absent from a set
|
|
30
30
|
* stored before it existed */
|
|
31
31
|
links?: Record<string, boolean>
|
|
32
|
+
/** path → its stat signature (`statSig`). For a path in `files` it is a
|
|
33
|
+
* fast path: an unchanged signature skips the content hash. For an
|
|
34
|
+
* absolute path outside the repo it is the whole record: a traced task
|
|
35
|
+
* reads its runtime and its tools there, which are too big to hash on
|
|
36
|
+
* every replay and never shared between checkouts anyway */
|
|
37
|
+
sigs?: Record<string, string>
|
|
38
|
+
/** local port → `closed`: a traced task met a refusal there, and its
|
|
39
|
+
* answer holds only while nothing listens on it */
|
|
40
|
+
ports?: Record<string, 'closed'>
|
|
41
|
+
/** symlink → its target, null when no link stands there: what a traced
|
|
42
|
+
* task learned from readlink or lstat */
|
|
43
|
+
targets?: Record<string, string | null>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** the target a symlink at `abs` points to, null when none stands there */
|
|
47
|
+
export function linkTarget(abs: string): string | null {
|
|
48
|
+
try {
|
|
49
|
+
return readlinkSync(abs)
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let listenMemo: { at: number; ports: Set<string> } | null = null
|
|
56
|
+
|
|
57
|
+
/** the TCP ports something listens on right now, on any local address. One
|
|
58
|
+
* netstat per second at most: a replay asks for several at once */
|
|
59
|
+
export function listeningPorts(now = Date.now()): Set<string> {
|
|
60
|
+
if (listenMemo != null && now - listenMemo.at < 1_000) return listenMemo.ports
|
|
61
|
+
const ports = new Set<string>()
|
|
62
|
+
const r = Bun.spawnSync(['/usr/sbin/netstat', '-an', '-p', 'tcp'], { stdout: 'pipe', stderr: 'pipe' })
|
|
63
|
+
for (const line of r.stdout.toString().split('\n')) {
|
|
64
|
+
if (!/\bLISTEN\b/.test(line)) continue
|
|
65
|
+
const local = line.trim().split(/\s+/)[3] ?? ''
|
|
66
|
+
const port = local.slice(local.lastIndexOf('.') + 1)
|
|
67
|
+
if (/^\d+$/.test(port)) ports.add(port)
|
|
68
|
+
}
|
|
69
|
+
listenMemo = { at: now, ports }
|
|
70
|
+
return ports
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** a path as recorded: repo-relative, or absolute for one outside the repo */
|
|
74
|
+
const at = (root: string, p: string): string => (isAbsolute(p) ? p : join(root, p))
|
|
75
|
+
|
|
76
|
+
/** what stat says about a file: size, modification time to the nanosecond
|
|
77
|
+
* and inode, `-` when nothing is there. Content can change under an equal
|
|
78
|
+
* signature only when a tool restores the mtime on purpose */
|
|
79
|
+
export function statSig(abs: string): string {
|
|
80
|
+
try {
|
|
81
|
+
const s = statSync(abs, { bigint: true })
|
|
82
|
+
return `${s.size}:${s.mtimeNs}:${s.ino}`
|
|
83
|
+
} catch {
|
|
84
|
+
return '-'
|
|
85
|
+
}
|
|
32
86
|
}
|
|
33
87
|
|
|
34
88
|
/** one entry of a directory, as `c.entries` returns it */
|
|
@@ -103,6 +157,9 @@ export class Reads {
|
|
|
103
157
|
readonly lists = new Map<string, string>()
|
|
104
158
|
readonly dirs = new Map<string, string | null>()
|
|
105
159
|
readonly links = new Map<string, boolean>()
|
|
160
|
+
readonly sigs = new Map<string, string>()
|
|
161
|
+
readonly ports = new Map<string, 'closed'>()
|
|
162
|
+
readonly targets = new Map<string, string | null>()
|
|
106
163
|
|
|
107
164
|
file(path: string, oid: string | null): void { this.files.set(path, oid) }
|
|
108
165
|
exist(path: string, kind: PathKind): void { this.exists.set(path, kind) }
|
|
@@ -116,11 +173,26 @@ export class Reads {
|
|
|
116
173
|
for (const [k, v] of o.lists) this.lists.set(k, v)
|
|
117
174
|
for (const [k, v] of o.dirs) this.dirs.set(k, v)
|
|
118
175
|
for (const [k, v] of o.links) this.links.set(k, v)
|
|
176
|
+
for (const [k, v] of o.sigs) this.sigs.set(k, v)
|
|
177
|
+
for (const [k, v] of o.ports) this.ports.set(k, v)
|
|
178
|
+
for (const [k, v] of o.targets) this.targets.set(k, v)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** a whole set, as a traced process read it (trace/read-trace.ts) */
|
|
182
|
+
mergeSet(s: ReadSet): void {
|
|
183
|
+
for (const [k, v] of Object.entries(s.files)) this.files.set(k, v)
|
|
184
|
+
for (const [k, v] of Object.entries(s.exists)) this.exists.set(k, v)
|
|
185
|
+
for (const [k, v] of Object.entries(s.lists)) this.lists.set(k, v)
|
|
186
|
+
for (const [k, v] of Object.entries(s.dirs ?? {})) this.dirs.set(k, v)
|
|
187
|
+
for (const [k, v] of Object.entries(s.links ?? {})) this.links.set(k, v)
|
|
188
|
+
for (const [k, v] of Object.entries(s.sigs ?? {})) this.sigs.set(k, v)
|
|
189
|
+
for (const [k, v] of Object.entries(s.ports ?? {})) this.ports.set(k, v)
|
|
190
|
+
for (const [k, v] of Object.entries(s.targets ?? {})) this.targets.set(k, v)
|
|
119
191
|
}
|
|
120
192
|
|
|
121
193
|
snapshot(): ReadSet {
|
|
122
194
|
const sorted = <V>(m: Map<string, V>): Record<string, V> => Object.fromEntries([...m].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
123
|
-
return { files: sorted(this.files), exists: sorted(this.exists), lists: sorted(this.lists), dirs: sorted(this.dirs), links: sorted(this.links) }
|
|
195
|
+
return { files: sorted(this.files), exists: sorted(this.exists), lists: sorted(this.lists), dirs: sorted(this.dirs), links: sorted(this.links), ...(this.sigs.size > 0 ? { sigs: sorted(this.sigs) } : {}), ...(this.ports.size > 0 ? { ports: sorted(this.ports) } : {}), ...(this.targets.size > 0 ? { targets: sorted(this.targets) } : {}) }
|
|
124
196
|
}
|
|
125
197
|
}
|
|
126
198
|
|
|
@@ -128,25 +200,41 @@ export class Reads {
|
|
|
128
200
|
* does. `list` answers a glob the way `c.list` would right now, and returns
|
|
129
201
|
* null when it cannot answer honestly (no git scan), which counts as drift. */
|
|
130
202
|
export function readSetDrift(root: string, set: ReadSet, list: (glob: string) => readonly string[] | null): string | null {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
203
|
+
const sigs = set.sigs ?? {}
|
|
204
|
+
for (const [p, sig] of Object.entries(sigs)) {
|
|
205
|
+
if (p in set.files) continue
|
|
206
|
+
const now = statSig(at(root, p))
|
|
207
|
+
if (now !== sig) return `${p} ${sig === '-' ? 'appeared' : now === '-' ? 'is gone' : 'changed'}`
|
|
134
208
|
}
|
|
135
209
|
for (const [p, kind] of Object.entries(set.exists)) {
|
|
136
|
-
const now = kindOf(
|
|
210
|
+
const now = kindOf(at(root, p))
|
|
137
211
|
if (now !== kind) return `${p} ${kind == null ? 'appeared' : now == null ? 'is gone' : `is a ${now} now`}`
|
|
138
212
|
}
|
|
213
|
+
for (const [p, oid] of Object.entries(set.files)) {
|
|
214
|
+
const abs = at(root, p)
|
|
215
|
+
if (sigs[p] != null && statSig(abs) === sigs[p]) continue
|
|
216
|
+
const now = oidOnDisk(abs)
|
|
217
|
+
if (now !== oid) return `${p} ${oid == null ? 'appeared' : now == null ? 'is gone' : 'changed'}`
|
|
218
|
+
}
|
|
139
219
|
for (const [g, hash] of Object.entries(set.lists)) {
|
|
140
220
|
const now = list(g)
|
|
141
221
|
if (now == null) return `${g} cannot be listed without a git scan`
|
|
142
222
|
if (listingHash(now) !== hash) return `the files matching ${g} changed`
|
|
143
223
|
}
|
|
144
224
|
for (const [d, hash] of Object.entries(set.dirs ?? {})) {
|
|
145
|
-
const now = entriesHash(dirEntries(
|
|
225
|
+
const now = entriesHash(dirEntries(at(root, d)))
|
|
146
226
|
if (now !== hash) return `${d}/ ${hash == null ? 'appeared' : now == null ? 'is gone' : 'changed its entries'}`
|
|
147
227
|
}
|
|
228
|
+
for (const [p, t] of Object.entries(set.targets ?? {})) {
|
|
229
|
+
if (linkTarget(at(root, p)) !== t) return `${p} points elsewhere now`
|
|
230
|
+
}
|
|
231
|
+
const closed = Object.keys(set.ports ?? {})
|
|
232
|
+
if (closed.length > 0) {
|
|
233
|
+
const open = listeningPorts()
|
|
234
|
+
for (const port of closed) if (open.has(port)) return `a service listens on local port ${port} now`
|
|
235
|
+
}
|
|
148
236
|
for (const [p, was] of Object.entries(set.links ?? {})) {
|
|
149
|
-
if (isLinkAt(
|
|
237
|
+
if (isLinkAt(at(root, p)) !== was) return `${p} ${was ? 'is no longer a link' : 'became a link'}`
|
|
150
238
|
}
|
|
151
239
|
return null
|
|
152
240
|
}
|
package/src/core/run-ci.ts
CHANGED
|
@@ -284,7 +284,12 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
284
284
|
// stored under a key that describes the file as it was before the edit.
|
|
285
285
|
// the counts line folds into the verdict; what else it says (a store
|
|
286
286
|
// that failed, answers thrown away) is printed as it is
|
|
287
|
-
if (ledger != null)
|
|
287
|
+
if (ledger != null) {
|
|
288
|
+
for (const [i, l] of ledger.header(ledger.commit()).entries()) (i === 0 ? detail : sink)(formatLine(l, false))
|
|
289
|
+
// why every check that did not replay ran, grouped: a re-run nobody
|
|
290
|
+
// can explain costs a reader more than the line does
|
|
291
|
+
for (const { line, quiet } of ledger.explain()) (quiet ? detail : sink)(formatLine(line, false))
|
|
292
|
+
}
|
|
288
293
|
// one write for the whole gate, after everything has finished
|
|
289
294
|
for (const l of runLog.flush()) sink(formatLine(l, false))
|
|
290
295
|
const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownOff.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// mechanism, two modes, rather than a formatter plus a format:check that
|
|
10
10
|
// drift apart.
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
12
|
-
import { dirname, join } from 'path'
|
|
12
|
+
import { dirname, isAbsolute, join } from 'path'
|
|
13
13
|
import { fileURLToPath } from 'url'
|
|
14
14
|
import { armHooks } from '../hooks/run-hooks.ts'
|
|
15
15
|
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
@@ -136,7 +136,13 @@ function fixMemo(root: string, tree: Map<string, string>, cmds: string[][]): str
|
|
|
136
136
|
const h = new Bun.CryptoHasher('sha256')
|
|
137
137
|
h.update(`head=${head.stdout.toString().trim()}\n`)
|
|
138
138
|
for (const [path, hash] of [...tree].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) h.update(`${path}\0${hash}\n`)
|
|
139
|
-
|
|
139
|
+
// the runner, the install the fixer scripts run from, and the toolkit code
|
|
140
|
+
// this pass and its built-in fixers reach, never the toolkit as a whole
|
|
141
|
+
try {
|
|
142
|
+
const t = toolchainIdentity(root, gateBinary())
|
|
143
|
+
const own = [fileURLToPath(import.meta.url), ...cmds.flat().filter((a) => isAbsolute(a) && t.lib.owns(a))]
|
|
144
|
+
h.update(`toolchain=${t.id}:${t.install}:${t.lib.closureKey(own)}\n`)
|
|
145
|
+
} catch { return null }
|
|
140
146
|
h.update(`cmds=${JSON.stringify(cmds)}\n`)
|
|
141
147
|
return h.digest('hex')
|
|
142
148
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// THE TRACED AUDIT: which gitignored paths a task reads that its key cannot
|
|
2
|
+
// see. A task's key covers the tracked tree and the paths `ignoredInputs`
|
|
3
|
+
// names; a generated type or a local fixture it reads outside those is a
|
|
4
|
+
// stale green waiting for its first edit. Each gate task runs once under the
|
|
5
|
+
// tracer (trace/tracer.ts), and the ignored paths it touched that no key
|
|
6
|
+
// covers become that task's proposals: the keys read them at once
|
|
7
|
+
// (core/audit-report.ts), and `sk trace-audit --fix` writes them into their
|
|
8
|
+
// own managed block of repo.config.ts (core/ignored-block.ts).
|
|
9
|
+
//
|
|
10
|
+
// The tracer never decides a verdict here. A hole in the trace (a child that
|
|
11
|
+
// ran untraced) costs this audit that task, said out loud, and never a green.
|
|
12
|
+
//
|
|
13
|
+
// The tracing costs about one traced gate, so the answer is kept under the
|
|
14
|
+
// git common dir and reused while it is young: rule SK031 in
|
|
15
|
+
// the health pass pays for it, and the fix pass that applies it reads it.
|
|
16
|
+
import { mkdirSync, writeFileSync } from 'fs'
|
|
17
|
+
import { dirname, posix } from 'path'
|
|
18
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
19
|
+
import { composeChecks } from '../ledger/gate-checks.ts'
|
|
20
|
+
import { isNamesInput } from '../ledger/git-key.ts'
|
|
21
|
+
import { readTrace } from '../trace/read-trace.ts'
|
|
22
|
+
import { dropTrace, learnBlind, traceUnavailable, tracedCommand } from '../trace/tracer.ts'
|
|
23
|
+
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
24
|
+
import { gitBin } from './git-bin.ts'
|
|
25
|
+
import { auditReportPath, ignoredFor, pendingByTask, readAuditReport, type AuditReport, type AuditTask, type IgnoredInputs } from './audit-report.ts'
|
|
26
|
+
import { addIgnoredInputs } from './ignored-block.ts'
|
|
27
|
+
import { loadRepoConfig } from '../repo-config.ts'
|
|
28
|
+
|
|
29
|
+
/** how long an audit answers for the tree: about a day, like SK030 */
|
|
30
|
+
export const AUDIT_KEEP_MS = 20 * 60 * 60 * 1000
|
|
31
|
+
|
|
32
|
+
export type { AuditReport, AuditTask } from './audit-report.ts'
|
|
33
|
+
export { readAuditReport } from './audit-report.ts'
|
|
34
|
+
|
|
35
|
+
/** the paths of `paths` git ignores */
|
|
36
|
+
function gitIgnored(root: string, paths: string[]): Set<string> {
|
|
37
|
+
if (paths.length === 0) return new Set()
|
|
38
|
+
const p = Bun.spawnSync([gitBin(), 'check-ignore', '--stdin'], { cwd: root, stdin: Buffer.from(`${paths.join('\n')}\n`), stdout: 'pipe', stderr: 'pipe' })
|
|
39
|
+
return new Set(p.stdout.toString().split('\n').filter((l) => l !== ''))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const under = (p: string, dir: string): boolean => p === dir || p.startsWith(`${dir}/`)
|
|
43
|
+
|
|
44
|
+
/** is this ignored path already in a key: bytes under a content entry, or a
|
|
45
|
+
* listing or probe under a names entry */
|
|
46
|
+
function covered(p: string, listing: boolean, extras: string[]): boolean {
|
|
47
|
+
return extras.some((e) => (isNamesInput(e) ? listing && under(p, e.slice(0, -3)) : under(p, e)))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** the fewest entries that cover what was read: each file by itself, or its
|
|
51
|
+
* dir once more than three files there were read; each listed dir as
|
|
52
|
+
* `dir/**`, the outermost one only */
|
|
53
|
+
export function proposeInputs(content: string[], listed: string[]): string[] {
|
|
54
|
+
const byDir = new Map<string, string[]>()
|
|
55
|
+
for (const f of content) byDir.set(posix.dirname(f), [...(byDir.get(posix.dirname(f)) ?? []), f])
|
|
56
|
+
const bytes = [...byDir].flatMap(([dir, files]) => (files.length > 3 && dir !== '.' ? [dir] : files))
|
|
57
|
+
const minimal = (xs: string[]): string[] => {
|
|
58
|
+
const sorted = [...new Set(xs)].sort()
|
|
59
|
+
return sorted.filter((x) => !sorted.some((y) => y !== x && under(x, y)))
|
|
60
|
+
}
|
|
61
|
+
return [...minimal(bytes), ...minimal(listed).map((d) => `${d}/**`)].sort()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** trace every gate task of the repo once and report what its key misses */
|
|
65
|
+
export async function traceAudit(root: string, config: RepoConfig, o: { fresh?: boolean; now?: number; env?: Record<string, string | undefined> } = {}): Promise<AuditReport> {
|
|
66
|
+
const now = o.now ?? Date.now()
|
|
67
|
+
const kept = readAuditReport(root)
|
|
68
|
+
if (o.fresh !== true && kept != null && kept.skipped == null && now - kept.at < AUDIT_KEEP_MS) return kept
|
|
69
|
+
const env = o.env ?? process.env
|
|
70
|
+
const off = traceUnavailable(env)
|
|
71
|
+
if (off != null) return { at: now, skipped: off, tasks: [] }
|
|
72
|
+
const tasks: AuditTask[] = []
|
|
73
|
+
for (const g of await composeChecks(root, config)) {
|
|
74
|
+
const u = g.runnable
|
|
75
|
+
if (u.body.cmd == null || g.row.runs.off != null || !g.row.runs.onCi || g.row.decl.inputs == null) continue
|
|
76
|
+
const cwd = u.body.cwd ?? root
|
|
77
|
+
const t = tracedCommand(u.body.cmd, cwd, env)
|
|
78
|
+
if ('reason' in t) { tasks.push({ id: u.id, status: 'unaudited', why: t.reason, uncovered: [], propose: [] }); continue }
|
|
79
|
+
try {
|
|
80
|
+
await spawnWithTimeout(t.cmd, { timeoutMs: u.timeoutMs, env: { ...process.env, ...u.body.env, ...t.env }, cwd })
|
|
81
|
+
const r = await readTrace(t.dir, root)
|
|
82
|
+
if (r.reads == null) {
|
|
83
|
+
const learned = r.blind.length > 0 ? learnBlind(r.blind) : null
|
|
84
|
+
tasks.push({ id: u.id, status: 'unaudited', why: [r.why, learned].filter((x) => x != null).join('; '), uncovered: [], propose: [] })
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
// bytes: a file it opened or stat'ed, or found by a probe; a
|
|
88
|
+
// listing: a dir it read the entries of, or found by a probe
|
|
89
|
+
// Finder rewrites .DS_Store at will, and no tool's verdict reads it
|
|
90
|
+
const rel = (p: string): boolean => !p.startsWith('/') && p !== '.' && !p.split('/').includes('node_modules') && !p.startsWith('.git/') && posix.basename(p) !== '.DS_Store'
|
|
91
|
+
const files = [...new Set([...Object.keys(r.reads.files), ...Object.keys(r.reads.sigs ?? {}), ...Object.entries(r.reads.exists).filter(([, k]) => k === 'file').map(([p]) => p)])].filter(rel)
|
|
92
|
+
const dirs = [...new Set([...Object.keys(r.reads.dirs ?? {}), ...Object.entries(r.reads.exists).filter(([, k]) => k === 'dir').map(([p]) => p)])].filter(rel)
|
|
93
|
+
const ignored = gitIgnored(root, [...files, ...dirs])
|
|
94
|
+
const extras = ignoredFor(config.ignoredInputs, u.id)
|
|
95
|
+
const missF = files.filter((p) => ignored.has(p) && !covered(p, false, extras))
|
|
96
|
+
const missD = dirs.filter((p) => ignored.has(p) && !covered(p, true, extras))
|
|
97
|
+
tasks.push({ id: u.id, status: 'audited', uncovered: [...missF, ...missD].sort(), propose: proposeInputs(missF, missD) })
|
|
98
|
+
} finally {
|
|
99
|
+
dropTrace(t.dir)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const report: AuditReport = { at: now, tasks }
|
|
103
|
+
try {
|
|
104
|
+
mkdirSync(dirname(auditReportPath(root)), { recursive: true })
|
|
105
|
+
writeFileSync(auditReportPath(root), JSON.stringify(report, null, 2))
|
|
106
|
+
} catch { /* an audit that cannot be kept is measured again next time */ }
|
|
107
|
+
return report
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** write what the kept audit proposes into the managed block. Nothing is
|
|
111
|
+
* traced here, so the fix pass can call it on every run */
|
|
112
|
+
export async function applyAudit(root: string): Promise<{ added: IgnoredInputs; note: string | null }> {
|
|
113
|
+
const r = readAuditReport(root)
|
|
114
|
+
if (r == null || r.skipped != null) return { added: {}, note: r?.skipped ?? 'no audit yet: sk trace-audit runs one' }
|
|
115
|
+
const added = pendingByTask(r, (await loadRepoConfig(root, { fresh: true })).ignoredInputs ?? {})
|
|
116
|
+
if (Object.keys(added).length === 0) return { added, note: null }
|
|
117
|
+
await addIgnoredInputs(root, added)
|
|
118
|
+
return { added, note: null }
|
|
119
|
+
}
|
|
120
|
+
|
package/src/home/gui-app.ts
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
// and iconed after it, in its repo's `.shipkit/wrapper/`. The bundle's
|
|
5
5
|
// Info.plist carries what differs (name, bundle id, start url), so a fix to
|
|
6
6
|
// the window (find, the inspector, zoom) lands in every app at its next
|
|
7
|
-
// build.
|
|
8
|
-
//
|
|
7
|
+
// build. The icon is core/app-icon.ts: the repo's mark, its margin cut, on
|
|
8
|
+
// a plate in the repo's colour. Rebuilt only when the source, the icon, the
|
|
9
|
+
// colours or the start url changed (a stamp in the bundle). No compiler, or not macOS: the caller falls back.
|
|
9
10
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
10
11
|
import { homedir } from 'os'
|
|
11
12
|
import { dirname, join } from 'path'
|
|
12
13
|
import { fileURLToPath } from 'url'
|
|
14
|
+
import { appIconOf, appIconPngs, identityOfRoot } from '../core/app-icon.ts'
|
|
15
|
+
import { identityOfName, type Identity } from '../core/identity-color.ts'
|
|
13
16
|
import { LAYOUT } from '../core/layout.ts'
|
|
14
17
|
|
|
15
18
|
const SOURCE = fileURLToPath(new URL('./gui-app/ShipkitGui.swift', import.meta.url))
|
|
@@ -17,15 +20,6 @@ const SOURCE = fileURLToPath(new URL('./gui-app/ShipkitGui.swift', import.meta.u
|
|
|
17
20
|
export const BUNDLE_ID = 'dev.shipkit.gui'
|
|
18
21
|
export const guiAppPath = (): string => process.env.SHIPKIT_GUI_APP ?? join(homedir(), 'Applications', 'Shipkit.app')
|
|
19
22
|
|
|
20
|
-
/** the icon the app wears: the repo's own mark, largest first. Only a
|
|
21
|
-
* png can become an icns */
|
|
22
|
-
export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, 'public/logo.png', 'public/favicon.png']
|
|
23
|
-
|
|
24
|
-
export function appIconOf(root: string): string | null {
|
|
25
|
-
for (const f of APP_ICON_FILES) if (existsSync(join(root, f))) return join(root, f)
|
|
26
|
-
return null
|
|
27
|
-
}
|
|
28
|
-
|
|
29
23
|
/** what one app bundle is: the rest comes from the shared source */
|
|
30
24
|
export type AppSpec = {
|
|
31
25
|
/** CFBundleName: the dock, cmd-tab and the app menu say it */
|
|
@@ -34,6 +28,8 @@ export type AppSpec = {
|
|
|
34
28
|
/** the page a launch with no url opens; null opens the home */
|
|
35
29
|
url: string | null
|
|
36
30
|
icon: string | null
|
|
31
|
+
/** the repo's colours: the plate behind the icon's mark */
|
|
32
|
+
identity: Identity
|
|
37
33
|
/** where the bundle lands */
|
|
38
34
|
app: string
|
|
39
35
|
version: string
|
|
@@ -52,7 +48,7 @@ function atsOf(url: string | null): string {
|
|
|
52
48
|
return `<dict><key>NSAllowsLocalNetworking</key><true/>${exception}</dict>`
|
|
53
49
|
}
|
|
54
50
|
|
|
55
|
-
export function infoPlist(o: Omit<AppSpec, 'app' | 'icon'>): string {
|
|
51
|
+
export function infoPlist(o: Omit<AppSpec, 'app' | 'icon' | 'identity'>): string {
|
|
56
52
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
57
53
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
58
54
|
<plist version="1.0">
|
|
@@ -79,6 +75,9 @@ const runSync: Run = (cmd) => {
|
|
|
79
75
|
return { code: p.exitCode ?? 1, err: p.stderr.toString().trim() }
|
|
80
76
|
}
|
|
81
77
|
|
|
78
|
+
/** bumped when the icon drawing changes, so every built app redraws its icon */
|
|
79
|
+
const ICON_DRAWING = 'plate-1'
|
|
80
|
+
|
|
82
81
|
/** the stamp a built bundle carries: what it was built from. The version is
|
|
83
82
|
* left out, so a shipkit release rebuilds nothing */
|
|
84
83
|
export function stampOf(source: string, icon: string | null, identity = ''): string {
|
|
@@ -89,11 +88,11 @@ export function stampOf(source: string, icon: string | null, identity = ''): str
|
|
|
89
88
|
|
|
90
89
|
/** one app, built or rebuilt when stale. Throws with the step that failed.
|
|
91
90
|
* `rebuilt` tells the caller a running copy is now the old one */
|
|
92
|
-
export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {}): { app: string; rebuilt: boolean } {
|
|
91
|
+
export async function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {}): Promise<{ app: string; rebuilt: boolean }> {
|
|
93
92
|
const { app, icon } = spec
|
|
94
93
|
const run = o.run ?? runSync
|
|
95
94
|
const source = o.source ?? SOURCE
|
|
96
|
-
const stamp = stampOf(readFileSync(source, 'utf8'), icon, JSON.stringify([spec.name, spec.bundleId, spec.url]))
|
|
95
|
+
const stamp = stampOf(readFileSync(source, 'utf8'), icon, JSON.stringify([spec.name, spec.bundleId, spec.url, spec.identity, ICON_DRAWING]))
|
|
97
96
|
const stampFile = join(app, 'Contents', 'Resources', 'stamp')
|
|
98
97
|
if (existsSync(stampFile) && readFileSync(stampFile, 'utf8') === stamp) return { app, rebuilt: false }
|
|
99
98
|
const tmp = `${app}.building-${process.pid}`
|
|
@@ -108,12 +107,10 @@ export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {})
|
|
|
108
107
|
if (icon != null) {
|
|
109
108
|
const set = join(res, 'AppIcon.iconset')
|
|
110
109
|
mkdirSync(set, { recursive: true })
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
}
|
|
110
|
+
const files = [16, 32, 128, 256, 512].flatMap((n) => [[`icon_${n}x${n}.png`, n], [`icon_${n}x${n}@2x.png`, n * 2]] as const)
|
|
111
|
+
const pngs = await appIconPngs(readFileSync(icon), spec.identity, files.map(([, px]) => px))
|
|
112
|
+
.catch((e: Error) => fail(`could not draw the icon from ${icon}: ${e.message}`))
|
|
113
|
+
files.forEach(([file], i) => writeFileSync(join(set, file), pngs[i]!))
|
|
117
114
|
const r = run(['iconutil', '-c', 'icns', set, '-o', join(res, 'AppIcon.icns')])
|
|
118
115
|
rmSync(set, { recursive: true, force: true })
|
|
119
116
|
if (r.code !== 0) fail(`iconutil could not build the icon: ${r.err.slice(0, 200)}`)
|
|
@@ -128,15 +125,15 @@ export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {})
|
|
|
128
125
|
}
|
|
129
126
|
|
|
130
127
|
/** the shipkit app: the shared source on the home, the house repo's icon */
|
|
131
|
-
export function ensureGuiApp(o: { root: string | null; version: string; app?: string; run?: Run }): { app: string; rebuilt: boolean } {
|
|
132
|
-
return ensureApp({ name: 'shipkit', bundleId: BUNDLE_ID, url: null, icon: o.root == null ? null : appIconOf(o.root), app: o.app ?? guiAppPath(), version: o.version }, { run: o.run })
|
|
128
|
+
export function ensureGuiApp(o: { root: string | null; version: string; app?: string; run?: Run }): Promise<{ app: string; rebuilt: boolean }> {
|
|
129
|
+
return ensureApp({ name: 'shipkit', bundleId: BUNDLE_ID, url: null, icon: o.root == null ? null : appIconOf(o.root), identity: o.root == null ? identityOfName('shipkit') : identityOfRoot(o.root), app: o.app ?? guiAppPath(), version: o.version }, { run: o.run })
|
|
133
130
|
}
|
|
134
131
|
|
|
135
132
|
/** what a global process gives its app */
|
|
136
133
|
export type WrapperOf = { id: string; label: string; url: string | null; icon: string | null; root: string }
|
|
137
134
|
|
|
138
|
-
/** the
|
|
139
|
-
const RASTER = /\.(png|jpe?g|gif|webp
|
|
135
|
+
/** the images the icon renderer reads; an emoji or an svg leaves the repo's mark */
|
|
136
|
+
const RASTER = /\.(png|jpe?g|gif|webp)$/i
|
|
140
137
|
|
|
141
138
|
export const wrapperBundleId = (id: string): string => `dev.shipkit.app.${id.replace(/[^A-Za-z0-9-]+/g, '-')}`
|
|
142
139
|
export const wrapperPath = (root: string, id: string): string => join(root, LAYOUT.wrapper, `${id.replace(/[^A-Za-z0-9._-]+/g, '-')}.app`)
|
|
@@ -147,12 +144,12 @@ export const wrapperPath = (root: string, id: string): string => join(root, LAYO
|
|
|
147
144
|
export function wrapperSpec(d: WrapperOf, version: string): AppSpec {
|
|
148
145
|
if (d.url == null) throw new Error(`${d.id} declares no url: there is no page to open as an app`)
|
|
149
146
|
const declared = d.icon != null && RASTER.test(d.icon) && existsSync(join(d.root, d.icon)) ? join(d.root, d.icon) : null
|
|
150
|
-
return { name: d.label, bundleId: wrapperBundleId(d.id), url: d.url, icon: declared ?? appIconOf(d.root), app: wrapperPath(d.root, d.id), version }
|
|
147
|
+
return { name: d.label, bundleId: wrapperBundleId(d.id), url: d.url, icon: declared ?? appIconOf(d.root), identity: identityOfRoot(d.root), app: wrapperPath(d.root, d.id), version }
|
|
151
148
|
}
|
|
152
149
|
|
|
153
|
-
export const ensureWrapperApp = (d: WrapperOf, o: { version: string; run?: Run }): { app: string; rebuilt: boolean; bundleId: string } => {
|
|
150
|
+
export const ensureWrapperApp = async (d: WrapperOf, o: { version: string; run?: Run }): Promise<{ app: string; rebuilt: boolean; bundleId: string }> => {
|
|
154
151
|
const spec = wrapperSpec(d, o.version)
|
|
155
|
-
return { ...ensureApp(spec, { run: o.run }), bundleId: spec.bundleId }
|
|
152
|
+
return { ...(await ensureApp(spec, { run: o.run })), bundleId: spec.bundleId }
|
|
156
153
|
}
|
|
157
154
|
|
|
158
155
|
/** quits a running copy of an app, and launches nothing when none runs */
|